From 52335dcbd792a6666d9077a5e83740ff297fcfdd Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Sep 2026 14:39:06 +0200 Subject: [PATCH] feat(tax-definitions): implement tax definition management features - Add useTaxDefinitionsSettingsPageController for managing tax definitions settings page state. - Create tax definition form entity and schema for validation. - Implement tax definition actions with permissions for edit, disable, and delete. - Develop UI components for tax definition management including cards, dialogs, and empty states. - Build utility functions for creating and updating tax definitions. - Add tests for tax definition form schema and utility functions. - Implement search and filter functionality for tax definitions list. - Create skeleton and error states for better user experience during loading and error scenarios. --- .../list-tax-definitions.use-case.ts | 17 +- modules/catalogs/src/common/locales/en.json | 35 +++ modules/catalogs/src/common/locales/es.json | 35 +++ modules/catalogs/src/web/catalogs-routes.tsx | 14 + modules/catalogs/src/web/index.ts | 1 + modules/catalogs/src/web/manifest.test.ts | 18 +- modules/catalogs/src/web/manifest.ts | 9 +- modules/catalogs/src/web/settings/index.ts | 1 + .../tax-definitions/controllers/index.ts | 2 + ...use-tax-definitions-settings-controller.ts | 198 ++++++++++++++ ...ax-definitions-settings-page-controller.ts | 61 +++++ .../tax-definitions/entities/index.ts | 2 + .../entities/tax-definition-form.entity.ts | 21 ++ .../tax-definition-form.schema.test.ts | 35 +++ .../entities/tax-definition-form.schema.ts | 54 ++++ .../src/web/settings/tax-definitions/index.ts | 4 + .../delete-tax-definition-dialog.tsx | 55 ++++ .../tax-definitions/ui/components/index.ts | 5 + .../ui/components/tax-definition-actions.tsx | 58 ++++ .../ui/components/tax-definition-card.tsx | 40 +++ .../components/tax-definition-form-dialog.tsx | 254 ++++++++++++++++++ .../tax-definitions-empty-state.tsx | 28 ++ .../tax-definitions-settings-skeleton.tsx | 11 + .../web/settings/tax-definitions/ui/index.ts | 2 + .../tax-definitions/ui/pages/index.ts | 1 + .../pages/tax-definitions-settings-page.tsx | 122 +++++++++ .../utils/build-tax-definition-form.ts | 34 +++ .../utils/build-tax-definition-params.test.ts | 44 +++ .../utils/build-tax-definition-params.ts | 54 ++++ ...uild-tax-definitions-list-criteria.test.ts | 15 ++ .../build-tax-definitions-list-criteria.ts | 23 ++ .../settings/tax-definitions/utils/index.ts | 4 + .../utils/tax-definition-actions.test.ts | 30 +++ .../utils/tax-definition-actions.ts | 6 + 34 files changed, 1289 insertions(+), 4 deletions(-) create mode 100644 modules/catalogs/src/web/settings/tax-definitions/controllers/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-controller.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/entities/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.entity.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.test.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/delete-tax-definition-dialog.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-actions.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-card.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-form-dialog.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-empty-state.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/pages/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-form.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.test.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.test.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/index.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts create mode 100644 modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.ts diff --git a/modules/catalogs/src/api/application/tax-definitions/use-cases/list-tax-definitions.use-case.ts b/modules/catalogs/src/api/application/tax-definitions/use-cases/list-tax-definitions.use-case.ts index af91236a..189244d7 100644 --- a/modules/catalogs/src/api/application/tax-definitions/use-cases/list-tax-definitions.use-case.ts +++ b/modules/catalogs/src/api/application/tax-definitions/use-cases/list-tax-definitions.use-case.ts @@ -19,7 +19,20 @@ export class ListTaxDefinitionsUseCase { return found; } - const collection = found.data.map((item) => this.deps.summarySnapshotBuilder.toOutput(item)); - return Result.ok(collection); + const taxDefinitions = found.data; + const totalItems = taxDefinitions.total(); + const items = taxDefinitions.map((item) => this.deps.summarySnapshotBuilder.toOutput(item)); + + return Result.ok({ + page: criteria.pageNumber, + per_page: criteria.pageSize, + total_pages: Math.ceil(totalItems / criteria.pageSize), + total_items: totalItems, + items, + metadata: { + entity: "tax_definitions", + criteria: criteria.toJSON(), + }, + }); } } diff --git a/modules/catalogs/src/common/locales/en.json b/modules/catalogs/src/common/locales/en.json index 712af8d3..79cfe20f 100644 --- a/modules/catalogs/src/common/locales/en.json +++ b/modules/catalogs/src/common/locales/en.json @@ -393,6 +393,41 @@ "description": "An unexpected error occurred while loading tax regimes." } } + }, + "taxDefinitions": { + "title": "Tax types", + "description": "Manage the tax types available for the company.", + "form": { "identity": "Tax identification", "calculation": "Calculation and jurisdiction", "validity": "Validity period", "texts": "Document texts" }, + "fields": { + "code": "Code", "name": "Name", "rate": "Rate (%)", + "taxFamily": "Tax family", "calculationBehavior": "Calculation", + "taxScope": "Scope", "country": "Country", "region": "Region", + "validFrom": "Valid from", "validTo": "Valid to", + "description": "Description", "invoiceNote": "Invoice note", + "allowedSurchargeCodes": "Allowed surcharges" + }, + "options": { + "family": { + "iva": "VAT", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Equivalence surcharge", "withholding": "Withholding", "vat": "International VAT", "gst": "Goods and services tax", "sales_tax": "Sales tax", "reverse_charge": "Reverse charge", "exempt": "Exempt", "not_subject": "Not subject", "custom": "Custom" + }, + "calculation": { + "additive": "Add to amount", "subtractive": "Subtract from amount", "neutral": "No amount effect" + }, + "scope": { + "domestic": "Domestic", "intra_eu": "Intra-EU", "export": "Export", "import": "Import", "international": "International" + }, + "country": { + "ES": "Spain", "FR": "France", "DE": "Germany", "IT": "Italy", "PT": "Portugal", "US": "United States", "MX": "Mexico", "AR": "Argentina" + } + }, + "actions": { "create": "New tax type", "save": "Save", "cancel": "Cancel", "edit": "Edit", "enable": "Enable", "disable": "Disable", "delete": "Delete", "more": "More actions", "retry": "Retry", "clearFilters": "Clear filters" }, + "status": { "all": "All", "active": "Active", "inactive": "Inactive", "system": "System" }, + "toolbar": { "search": "Search tax types", "status": "Status" }, + "create": { "title": "New tax type", "description": "Create a tax type for this company." }, + "edit": { "title": "Edit tax type", "description": "Update the selected tax type." }, + "delete": { "title": "Delete tax type", "description": "Delete {{code}} — {{name}}?" }, + "empty": { "default": "There are no tax types configured.", "filtered": "No results match the selected filters." }, + "error": { "title": "The settings could not be loaded" } } } } diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json index 73fb7276..c32a7ac6 100644 --- a/modules/catalogs/src/common/locales/es.json +++ b/modules/catalogs/src/common/locales/es.json @@ -393,6 +393,41 @@ "description": "Se produjo un error inesperado al obtener los regímenes fiscales." } } + }, + "taxDefinitions": { + "title": "Tipos de impuesto", + "description": "Gestiona los tipos de impuesto disponibles para la empresa.", + "form": { "identity": "Identificación fiscal", "calculation": "Cálculo y jurisdicción", "validity": "Periodo de vigencia", "texts": "Textos del documento" }, + "fields": { + "code": "Código", "name": "Nombre", "rate": "Tipo (%)", + "taxFamily": "Familia fiscal", "calculationBehavior": "Cálculo", + "taxScope": "Ámbito", "country": "País", "region": "Región", + "validFrom": "Válido desde", "validTo": "Válido hasta", + "description": "Descripción", "invoiceNote": "Nota en factura", + "allowedSurchargeCodes": "Recargos permitidos" + }, + "options": { + "family": { + "iva": "IVA", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Recargo de equivalencia", "withholding": "Retención", "vat": "IVA internacional", "gst": "Impuesto sobre bienes y servicios", "sales_tax": "Impuesto sobre ventas", "reverse_charge": "Inversión del sujeto pasivo", "exempt": "Exento", "not_subject": "No sujeto", "custom": "Personalizado" + }, + "calculation": { + "additive": "Añadir al importe", "subtractive": "Descontar del importe", "neutral": "Sin efecto en el importe" + }, + "scope": { + "domestic": "Nacional", "intra_eu": "Intracomunitario", "export": "Exportación", "import": "Importación", "international": "Internacional" + }, + "country": { + "ES": "España", "FR": "Francia", "DE": "Alemania", "IT": "Italia", "PT": "Portugal", "US": "Estados Unidos", "MX": "México", "AR": "Argentina" + } + }, + "actions": { "create": "Nuevo tipo", "save": "Guardar", "cancel": "Cancelar", "edit": "Editar", "enable": "Activar", "disable": "Desactivar", "delete": "Eliminar", "more": "Más acciones", "retry": "Reintentar", "clearFilters": "Limpiar filtros" }, + "status": { "all": "Todos", "active": "Activo", "inactive": "Inactivo", "system": "Sistema" }, + "toolbar": { "search": "Buscar tipos de impuesto", "status": "Estado" }, + "create": { "title": "Nuevo tipo de impuesto", "description": "Crea un tipo de impuesto para esta empresa." }, + "edit": { "title": "Editar tipo de impuesto", "description": "Actualiza el tipo de impuesto seleccionado." }, + "delete": { "title": "Eliminar tipo de impuesto", "description": "¿Eliminar {{code}} — {{name}}?" }, + "empty": { "default": "No hay tipos de impuesto configurados.", "filtered": "No hay resultados para los filtros seleccionados." }, + "error": { "title": "No se pudo cargar la configuración" } } } } diff --git a/modules/catalogs/src/web/catalogs-routes.tsx b/modules/catalogs/src/web/catalogs-routes.tsx index b63609cc..ac05b71a 100644 --- a/modules/catalogs/src/web/catalogs-routes.tsx +++ b/modules/catalogs/src/web/catalogs-routes.tsx @@ -19,6 +19,12 @@ const TaxRegimesSettingsPage = lazy(() => })) ); +const TaxDefinitionsSettingsPage = lazy(() => + import("./settings/tax-definitions").then((module) => ({ + default: module.TaxDefinitionsSettingsPage, + })) +); + export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => { return [ { @@ -45,5 +51,13 @@ export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[] }, element: , }, + { + path: "settings/general/tax-definitions", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, ]; }; diff --git a/modules/catalogs/src/web/index.ts b/modules/catalogs/src/web/index.ts index 74e10b7c..3b1d7830 100644 --- a/modules/catalogs/src/web/index.ts +++ b/modules/catalogs/src/web/index.ts @@ -5,3 +5,4 @@ export { export * from "./payment-methods"; export * from "./payment-terms"; export * from "./tax-regimes"; +export * from "./settings/tax-definitions"; diff --git a/modules/catalogs/src/web/manifest.test.ts b/modules/catalogs/src/web/manifest.test.ts index 5bdddcc0..c750ac0c 100644 --- a/modules/catalogs/src/web/manifest.test.ts +++ b/modules/catalogs/src/web/manifest.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { CalendarClockIcon, CreditCardIcon, LandmarkIcon } from "lucide-react"; +import { CalendarClockIcon, CreditCardIcon, LandmarkIcon, ReceiptTextIcon } from "lucide-react"; import { CatalogsModuleManifest } from "./manifest"; @@ -29,6 +29,13 @@ test("publica la entry de Settings para payment methods", () => { labelKey: "settings.taxRegimes.title", path: "/settings/general/tax-regimes", }, + { + id: "tax-definitions", + icon: ReceiptTextIcon, + sectionId: "general", + labelKey: "settings.taxDefinitions.title", + path: "/settings/general/tax-definitions", + }, ], }); }); @@ -59,3 +66,12 @@ test("expone una ruta modular para settings/general/tax-regimes", () => { assert.equal(settingsRoute?.handle?.layout, "app-sidebar"); assert.equal(settingsRoute?.handle?.protected, true); }); + +test("expone una ruta modular para settings/general/tax-definitions", () => { + const routes = CatalogsModuleManifest.routes?.({}); + const settingsRoute = routes?.find((route) => route.path === "settings/general/tax-definitions"); + + assert.ok(settingsRoute); + assert.equal(settingsRoute?.handle?.layout, "app-sidebar"); + assert.equal(settingsRoute?.handle?.protected, true); +}); diff --git a/modules/catalogs/src/web/manifest.ts b/modules/catalogs/src/web/manifest.ts index d3667f88..268e049e 100644 --- a/modules/catalogs/src/web/manifest.ts +++ b/modules/catalogs/src/web/manifest.ts @@ -1,5 +1,5 @@ import type { IModuleClient } from "@erp/core/client"; -import { CalendarClockIcon, CreditCardIcon, LandmarkIcon } from "lucide-react"; +import { CalendarClockIcon, CreditCardIcon, LandmarkIcon, ReceiptTextIcon } from "lucide-react"; import { CatalogsRoutes } from "./catalogs-routes"; import { ensureModuleTranslations } from "./i18n"; @@ -38,6 +38,13 @@ export const CatalogsModuleManifest: IModuleClient = { labelKey: "settings.taxRegimes.title", path: "/settings/general/tax-regimes", }, + { + id: "tax-definitions", + icon: ReceiptTextIcon, + sectionId: "general", + labelKey: "settings.taxDefinitions.title", + path: "/settings/general/tax-definitions", + }, ], }, routes: (params) => CatalogsRoutes(params), diff --git a/modules/catalogs/src/web/settings/index.ts b/modules/catalogs/src/web/settings/index.ts index 23f8279d..252aecca 100644 --- a/modules/catalogs/src/web/settings/index.ts +++ b/modules/catalogs/src/web/settings/index.ts @@ -1,3 +1,4 @@ export * from "./payment-methods"; export * from "./payment-terms"; export * from "./tax-regimes"; +export * from "./tax-definitions"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/controllers/index.ts b/modules/catalogs/src/web/settings/tax-definitions/controllers/index.ts new file mode 100644 index 00000000..f2bb64ef --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./use-tax-definitions-settings-controller"; +export * from "./use-tax-definitions-settings-page-controller"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-controller.ts b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-controller.ts new file mode 100644 index 00000000..5cff5fd5 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-controller.ts @@ -0,0 +1,198 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { useHookForm } from "@erp/core/hooks"; +import { UniqueID, isValidationErrorCollection } from "@repo/rdx-ddd"; +import type { FormEvent } from "react"; +import { useCallback, useEffect, useId, useState } from "react"; + +import { + type TaxDefinitionListRow, + useTaxDefinitionCreateMutation, + useTaxDefinitionDeleteMutation, + useTaxDefinitionDetailQuery, + useTaxDefinitionDisableMutation, + useTaxDefinitionEnableMutation, + useTaxDefinitionUpdateMutation, +} from "../../../tax-definitions/shared"; +import { type TaxDefinitionForm, TaxDefinitionFormSchema } from "../entities"; +import { + buildCreateTaxDefinitionParams, + buildTaxDefinitionFormDefault, + buildTaxDefinitionFormFromEntity, + buildUpdateTaxDefinitionByIdParams, + canDeleteTaxDefinition, + canDisableTaxDefinition, + canEditTaxDefinition, +} from "../utils"; + +export const useTaxDefinitionsSettingsController = () => { + const formId = useId(); + const createMutation = useTaxDefinitionCreateMutation(); + const updateMutation = useTaxDefinitionUpdateMutation(); + const deleteMutation = useTaxDefinitionDeleteMutation(); + const enableMutation = useTaxDefinitionEnableMutation(); + const disableMutation = useTaxDefinitionDisableMutation(); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [editingTaxDefinitionId, setEditingTaxDefinitionId] = useState(null); + const [deletingTaxDefinition, setDeletingTaxDefinition] = useState( + null + ); + + const [lastError, setLastError] = useState(null); + + const editingQuery = useTaxDefinitionDetailQuery(editingTaxDefinitionId ?? undefined, { + enabled: editingTaxDefinitionId !== null, + }); + const editingTaxDefinition = editingQuery.data ?? null; + + const isSaving = createMutation.isPending || updateMutation.isPending || editingQuery.isLoading; + const isTogglingActive = enableMutation.isPending || disableMutation.isPending; + + const form = useHookForm({ + resolverSchema: TaxDefinitionFormSchema, + initialValues: buildTaxDefinitionFormDefault(), + disabled: isSaving, + }); + + const reset = useCallback(() => { + form.reset(buildTaxDefinitionFormDefault(), { keepDirty: false }); + form.clearErrors(); + setLastError(null); + }, [form]); + + const openCreateDialog = useCallback(() => { + setEditingTaxDefinitionId(null); + reset(); + setIsCreateOpen(true); + }, [reset]); + + const closeCreateDialog = useCallback(() => { + if (!isSaving) { + setIsCreateOpen(false); + reset(); + } + }, [isSaving, reset]); + + const openEditDialog = useCallback( + (row: TaxDefinitionListRow) => { + if (canEditTaxDefinition(row)) { + setIsCreateOpen(false); + setEditingTaxDefinitionId(row.id); + reset(); + } + }, + [reset] + ); + + const closeEditDialog = useCallback(() => { + if (!isSaving) { + setEditingTaxDefinitionId(null); + reset(); + } + }, [isSaving, reset]); + + const openDeleteDialog = useCallback((row: TaxDefinitionListRow) => { + if (canDeleteTaxDefinition(row)) { + setDeletingTaxDefinition(row); + } + }, []); + + const closeDeleteDialog = useCallback(() => { + if (!deleteMutation.isPending) { + setDeletingTaxDefinition(null); + } + }, [deleteMutation.isPending]); + + const submit = form.handleSubmit(async (values) => { + try { + if (editingTaxDefinition) { + await updateMutation.mutateAsync( + buildUpdateTaxDefinitionByIdParams(editingTaxDefinition.id, values) + ); + } else { + await createMutation.mutateAsync( + buildCreateTaxDefinitionParams(UniqueID.generateNewID().toString(), values) + ); + } + if (editingTaxDefinition) { + closeEditDialog(); + } else { + closeCreateDialog(); + } + } catch (error: unknown) { + if (isValidationErrorCollection(error)) { + applyValidationErrorCollection(form, error); + } else { + setLastError(error instanceof Error ? error : new Error("Unknown error")); + } + } + }); + + const onSubmit = useCallback( + (event: FormEvent) => { + submit(event).catch((error: unknown) => + setLastError(error instanceof Error ? error : new Error("Unknown error")) + ); + }, + [submit] + ); + + const enable = useCallback( + async (row: TaxDefinitionListRow) => { + if (!isTogglingActive) { + await enableMutation.mutateAsync({ taxDefinitionId: row.id }); + } + }, + [enableMutation, isTogglingActive] + ); + + const disable = useCallback( + async (row: TaxDefinitionListRow) => { + if (canDisableTaxDefinition(row) && !isTogglingActive) { + await disableMutation.mutateAsync({ taxDefinitionId: row.id }); + } + }, + [disableMutation, isTogglingActive] + ); + + const confirmDelete = useCallback(async () => { + if (deletingTaxDefinition && !deleteMutation.isPending) { + await deleteMutation.mutateAsync({ taxDefinitionId: deletingTaxDefinition.id }); + closeDeleteDialog(); + } + }, [closeDeleteDialog, deleteMutation, deletingTaxDefinition]); + + useEffect(() => { + if (editingTaxDefinition) { + form.reset(buildTaxDefinitionFormFromEntity(editingTaxDefinition), { keepDirty: false }); + } + }, [editingTaxDefinition, form]); + + return { + form, + formId, + + isCreateOpen, + editingTaxDefinition, + isEditingTaxDefinitionLoading: editingQuery.isLoading, + editingTaxDefinitionError: editingQuery.error, + + deletingTaxDefinition, + + isSaving, + isDeleting: deleteMutation.isPending, + isTogglingActive, + lastError, + + openCreateDialog, + closeCreateDialog, + openEditDialog, + closeEditDialog, + openDeleteDialog, + closeDeleteDialog, + onSubmit, + enable, + disable, + confirmDelete, + }; +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts new file mode 100644 index 00000000..09ea82b2 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts @@ -0,0 +1,61 @@ +import { useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; + +import { useTaxDefinitionListQuery } from "../../../tax-definitions/shared"; +import { + type TaxDefinitionsStatusFilter, + buildTaxDefinitionsListCriteria, + hasTaxDefinitionsListCriteriaApplied, +} from "../utils"; + +import { useTaxDefinitionsSettingsController } from "./use-tax-definitions-settings-controller"; + +export const useTaxDefinitionsSettingsPageController = () => { + const [search, setSearch] = useState(""); + const [status, setStatus] = useState("all"); + + const [debouncedSearch] = useDebounce(search, 300); + + const criteria = useMemo( + () => buildTaxDefinitionsListCriteria({ search: debouncedSearch, status }), + [debouncedSearch, status] + ); + + const listQuery = useTaxDefinitionListQuery({ criteria }); + const controller = useTaxDefinitionsSettingsController(); + + const totalItems = listQuery.data?.totalItems ?? 0; + const isInitialLoading = listQuery.isLoading; + + const hasAppliedCriteria = hasTaxDefinitionsListCriteriaApplied({ + search: debouncedSearch, + status, + }); + + const retry = () => { + listQuery.refetch(); + }; + + return { + list: listQuery.data?.items ?? [], + totalItems, + isInitialLoading, + isBackgroundFetching: listQuery.isFetching && !isInitialLoading, + hasNoResults: !isInitialLoading && totalItems === 0 && hasAppliedCriteria, + hasEmptyState: !isInitialLoading && totalItems === 0 && !hasAppliedCriteria, + hasAppliedCriteria, + search, + setSearch, + status, + setStatus, + clearFilters: () => { + setSearch(""); + setStatus("all"); + }, + criteria, + pageError: listQuery.isError ? listQuery.error : null, + retry, + listQuery, + controller, + }; +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/entities/index.ts b/modules/catalogs/src/web/settings/tax-definitions/entities/index.ts new file mode 100644 index 00000000..5eaa7a0f --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/entities/index.ts @@ -0,0 +1,2 @@ +export * from "./tax-definition-form.entity"; +export * from "./tax-definition-form.schema"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.entity.ts b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.entity.ts new file mode 100644 index 00000000..a1070345 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.entity.ts @@ -0,0 +1,21 @@ +import type { + TaxCalculationBehavior, + TaxDefinitionFamily, + TaxScope, +} from "../../../tax-definitions/shared"; + +export interface TaxDefinitionForm { + code: string; + name: string; + description: string | null; + rate: number; + taxFamily: TaxDefinitionFamily; + calculationBehavior: TaxCalculationBehavior; + jurisdictionCountryCode: string; + jurisdictionRegionCode: string | null; + taxScope: TaxScope; + invoiceNote: string | null; + allowedSurchargeCodes: string[] | null; + validFrom: string | null; + validTo: string | null; +} diff --git a/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.test.ts b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.test.ts new file mode 100644 index 00000000..605da0e1 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { TaxDefinitionFormSchema } from "./tax-definition-form.schema"; + +const valid = { + code: "iva_21", + name: "IVA", + description: null, + rate: 21, + taxFamily: "iva", + calculationBehavior: "additive", + jurisdictionCountryCode: "ES", + jurisdictionRegionCode: null, + taxScope: "domestic", + invoiceNote: null, + allowedSurchargeCodes: [], + validFrom: "2026-01-01", + validTo: "2026-12-31", +}; +test("acepta formulario válido y vigencias creciente o igual", () => { + assert.equal(TaxDefinitionFormSchema.safeParse(valid).success, true); + assert.equal( + TaxDefinitionFormSchema.safeParse({ ...valid, validTo: "2026-01-01" }).success, + true + ); +}); +test("rechaza nombre, rate y vigencias inválidas", () => { + assert.equal(TaxDefinitionFormSchema.safeParse({ ...valid, name: "" }).success, false); + assert.equal(TaxDefinitionFormSchema.safeParse({ ...valid, rate: 101 }).success, false); + assert.equal( + TaxDefinitionFormSchema.safeParse({ ...valid, validFrom: "2027-01-01" }).success, + false + ); +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.ts b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.ts new file mode 100644 index 00000000..58fa465f --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/entities/tax-definition-form.schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; + +const nullableText = z.string().nullable(); +const optionalDate = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable(); + +export const TaxDefinitionFormSchema = z + .object({ + code: z + .string() + .trim() + .regex(/^[a-z0-9][a-z0-9_]*$/), + name: z.string().trim().min(1), + description: nullableText, + rate: z.number().finite().min(0).max(100), + taxFamily: z.enum([ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", + ]), + calculationBehavior: z.enum(["additive", "subtractive", "neutral"]), + jurisdictionCountryCode: z.string().regex(/^[A-Z]{2}$/), + jurisdictionRegionCode: z + .string() + .regex(/^[A-Z]{2}-[A-Z0-9-]+$/) + .nullable(), + taxScope: z.enum(["domestic", "intra_eu", "export", "import", "international"]), + invoiceNote: nullableText, + allowedSurchargeCodes: z.array(z.string().regex(/^[a-z0-9][a-z0-9_]*$/)).nullable(), + validFrom: optionalDate, + validTo: optionalDate, + }) + .superRefine((value, context) => { + if (value.validFrom !== null && value.validTo !== null && value.validFrom > value.validTo) { + context.addIssue({ + code: "custom", + path: ["validTo"], + message: "La fecha final debe ser posterior o igual a la inicial.", + }); + } + }); + +export type TaxDefinitionFormValues = z.infer; diff --git a/modules/catalogs/src/web/settings/tax-definitions/index.ts b/modules/catalogs/src/web/settings/tax-definitions/index.ts new file mode 100644 index 00000000..e498b784 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/index.ts @@ -0,0 +1,4 @@ +export * from "./controllers"; +export * from "./entities"; +export * from "./ui"; +export * from "./utils"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/delete-tax-definition-dialog.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/delete-tax-definition-dialog.tsx new file mode 100644 index 00000000..cd46c569 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/delete-tax-definition-dialog.tsx @@ -0,0 +1,55 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import type { TaxDefinitionListRow } from "../../../../tax-definitions/shared"; +export const DeleteTaxDefinitionDialog = ({ + open, + onOpenChange, + target, + onConfirm, + isSubmitting, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + target: TaxDefinitionListRow | null; + onConfirm: () => void; + isSubmitting: boolean; +}) => { + const { t } = useTranslation(); + return ( + + + + {t("settings.taxDefinitions.delete.title")} + + {t("settings.taxDefinitions.delete.description", { + code: target?.code ?? "", + name: target?.name ?? "", + })} + + + + + + + + + ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts b/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts new file mode 100644 index 00000000..33a31ece --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts @@ -0,0 +1,5 @@ +export * from "./delete-tax-definition-dialog"; +export * from "./tax-definition-card"; +export * from "./tax-definition-form-dialog"; +export * from "./tax-definitions-empty-state"; +export * from "./tax-definitions-settings-skeleton"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-actions.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-actions.tsx new file mode 100644 index 00000000..ab668670 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-actions.tsx @@ -0,0 +1,58 @@ +import { type RdxRowAction, RdxRowActions } from "@repo/rdx-ui/components"; +import { PencilIcon, PowerIcon, PowerOffIcon, Trash2Icon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import type { TaxDefinitionListRow } from "../../../../tax-definitions/shared"; +import { canDeleteTaxDefinition, canDisableTaxDefinition, canEditTaxDefinition } from "../../utils"; + +export const TaxDefinitionActions = ({ + item, + onEdit, + onToggle, + onDelete, +}: { + item: TaxDefinitionListRow; + onEdit: (item: TaxDefinitionListRow) => void; + onToggle: (item: TaxDefinitionListRow) => void; + onDelete: (item: TaxDefinitionListRow) => void; +}) => { + const { t } = useTranslation(); + const actions: RdxRowAction[] = [ + { + id: "edit", + label: t("settings.taxDefinitions.actions.edit"), + icon: , + disabled: !canEditTaxDefinition(item), + onSelect: () => onEdit(item), + }, + { + id: "toggle", + label: item.isActive + ? t("settings.taxDefinitions.actions.disable") + : t("settings.taxDefinitions.actions.enable"), + icon: item.isActive ? ( + + ) : ( + + ), + disabled: item.isActive && !canDisableTaxDefinition(item), + onSelect: () => onToggle(item), + }, + ]; + return ( + , + destructive: true, + disabled: !canDeleteTaxDefinition(item), + onSelect: () => onDelete(item), + }, + ]} + overflowLabel={t("settings.taxDefinitions.actions.more")} + primaryActions={actions} + /> + ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-card.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-card.tsx new file mode 100644 index 00000000..09e25b83 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-card.tsx @@ -0,0 +1,40 @@ +import { RdxBadge } from "@repo/rdx-ui/components"; +import { Card, CardContent } from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import type { TaxDefinitionListRow } from "../../../../tax-definitions/shared"; + +import { TaxDefinitionActions } from "./tax-definition-actions"; + +export const TaxDefinitionCard = ({ + item, + onEdit, + onToggle, + onDelete, +}: { + item: TaxDefinitionListRow; + onEdit: (item: TaxDefinitionListRow) => void; + onToggle: (item: TaxDefinitionListRow) => void; + onDelete: (item: TaxDefinitionListRow) => void; +}) => { + const { t } = useTranslation(); + return ( + + +
+

{item.code}

+

{item.name}

+
+ + {item.isActive + ? t("settings.taxDefinitions.status.active") + : t("settings.taxDefinitions.status.inactive")} + + {item.isSystem && ( + {t("settings.taxDefinitions.status.system")} + )} + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-form-dialog.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-form-dialog.tsx new file mode 100644 index 00000000..9e96a4d6 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definition-form-dialog.tsx @@ -0,0 +1,254 @@ +import { + DatePickerField, + RdxFormGrid, + SelectField, + TextAreaField, + TextField, +} from "@repo/rdx-ui/components"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Spinner, +} from "@repo/shadcn-ui/components"; +import { CalendarDaysIcon, LandmarkIcon, ReceiptTextIcon, Settings2Icon } from "lucide-react"; +import { type FormEventHandler, type ReactNode, useEffect } from "react"; +import { FormProvider, type UseFormReturn } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import type { TaxDefinitionForm } from "../../entities"; + +const familyValues = [ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", +]; +const calculationValues = ["additive", "subtractive", "neutral"]; +const scopeValues = ["domestic", "intra_eu", "export", "import", "international"]; +const countryValues = ["ES", "FR", "DE", "IT", "PT", "US", "MX", "AR"]; + +const Section = ({ + icon: Icon, + title, + children, +}: { + icon: typeof ReceiptTextIcon; + title: string; + children: ReactNode; +}) => ( +
+
+ + + +

{title}

+
+ {children} +
+); + +export const TaxDefinitionFormDialog = ({ + open, + onOpenChange, + form, + formId, + onSubmit, + isSubmitting, + isEditMode, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + form: UseFormReturn; + formId: string; + onSubmit: FormEventHandler; + isSubmitting: boolean; + isEditMode: boolean; +}) => { + const { t } = useTranslation(); + const immutable = isSubmitting || isEditMode; + const families = familyValues.map((value) => ({ + value, + label: t(`settings.taxDefinitions.options.family.${value}`), + })); + const behaviors = calculationValues.map((value) => ({ + value, + label: t(`settings.taxDefinitions.options.calculation.${value}`), + })); + const scopes = scopeValues.map((value) => ({ + value, + label: t(`settings.taxDefinitions.options.scope.${value}`), + })); + const countries = countryValues.map((value) => ({ + value, + label: t(`settings.taxDefinitions.options.country.${value}`), + })); + + useEffect(() => { + if (open) { + form.setFocus(isEditMode ? "name" : "code"); + } + }, [form, isEditMode, open]); + + return ( + !isSubmitting && onOpenChange(nextOpen)} open={open}> + + + + {t( + isEditMode + ? "settings.taxDefinitions.edit.title" + : "settings.taxDefinitions.create.title" + )} + + + {t( + isEditMode + ? "settings.taxDefinitions.edit.description" + : "settings.taxDefinitions.create.description" + )} + + + +
+
+ + + + %} + typePreset="number" + /> + + disabled={immutable} + items={families} + label={t("settings.taxDefinitions.fields.taxFamily")} + name="taxFamily" + /> + +
+
+ + + disabled={immutable} + items={behaviors} + label={t("settings.taxDefinitions.fields.calculationBehavior")} + name="calculationBehavior" + /> + + disabled={immutable} + items={scopes} + label={t("settings.taxDefinitions.fields.taxScope")} + name="taxScope" + /> + + disabled={immutable} + items={countries} + label={t("settings.taxDefinitions.fields.country")} + name="jurisdictionCountryCode" + /> + + +
+
+ + + className="w-full" + disabled={isSubmitting} + label={t("settings.taxDefinitions.fields.validFrom")} + name="validFrom" + /> + + className="w-full" + disabled={isSubmitting} + label={t("settings.taxDefinitions.fields.validTo")} + name="validTo" + /> + +
+
+ + + + +
+
+
+ + + + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-empty-state.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-empty-state.tsx new file mode 100644 index 00000000..dfcfc6c6 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-empty-state.tsx @@ -0,0 +1,28 @@ +import { Button } from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +export const TaxDefinitionsEmptyState = ({ + filtered, + onCreate, + onClear, +}: { + filtered: boolean; + onCreate: () => void; + onClear: () => void; +}) => { + const { t } = useTranslation(); + return ( +
+

+ {filtered + ? t("settings.taxDefinitions.empty.filtered") + : t("settings.taxDefinitions.empty.default")} +

+ +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx new file mode 100644 index 00000000..5a7adc57 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx @@ -0,0 +1,11 @@ +import { Card, Skeleton } from "@repo/shadcn-ui/components"; +export const TaxDefinitionsSettingsSkeleton = () => ( +
+ + {[1, 2, 3].map((id) => ( + + + + ))} +
+); diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/index.ts b/modules/catalogs/src/web/settings/tax-definitions/ui/index.ts new file mode 100644 index 00000000..90d54b57 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./components"; +export * from "./pages"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/pages/index.ts b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/index.ts new file mode 100644 index 00000000..7ab10709 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./tax-definitions-settings-page"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx new file mode 100644 index 00000000..881d448a --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx @@ -0,0 +1,122 @@ +import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; +import { + Button, + Card, + CardContent, + CardHeader, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/shadcn-ui/components"; +import { PlusIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import { useTaxDefinitionsSettingsPageController } from "../../controllers"; +import type { TaxDefinitionsStatusFilter } from "../../utils"; +import { + DeleteTaxDefinitionDialog, + TaxDefinitionCard, + TaxDefinitionFormDialog, + TaxDefinitionsEmptyState, + TaxDefinitionsSettingsSkeleton, +} from "../components"; + +export const TaxDefinitionsSettingsPage = () => { + const { t } = useTranslation(); + const pageCtrl = useTaxDefinitionsSettingsPageController(); + const featureCtrl = pageCtrl.controller; + const status = (value: TaxDefinitionsStatusFilter | null) => pageCtrl.setStatus(value ?? "all"); + + if (pageCtrl.isInitialLoading) { + return ; + } + + if (pageCtrl.pageError) { + return ( + + ); + } + + return ( + <> + + +
+

{t("settings.taxDefinitions.title")}

+

{t("settings.taxDefinitions.description")}

+
+ +
+ +
+ + +
+ {pageCtrl.list.length ? ( + pageCtrl.list.map((item) => ( + + row.isActive ? featureCtrl.disable(row) : featureCtrl.enable(row) + } + /> + )) + ) : ( + + )} +
+
+ !open && featureCtrl.closeCreateDialog()} + onSubmit={featureCtrl.onSubmit} + open={featureCtrl.isCreateOpen} + /> + !open && featureCtrl.closeDeleteDialog()} + open={Boolean(featureCtrl.deletingTaxDefinition)} + target={featureCtrl.deletingTaxDefinition} + /> + + ); +}; diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-form.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-form.ts new file mode 100644 index 00000000..42059f11 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-form.ts @@ -0,0 +1,34 @@ +import type { TaxDefinition } from "../../../tax-definitions/shared"; +import type { TaxDefinitionForm } from "../entities"; + +export const buildTaxDefinitionFormDefault = (): TaxDefinitionForm => ({ + code: "", + name: "", + description: null, + rate: 0, + taxFamily: "iva", + calculationBehavior: "additive", + jurisdictionCountryCode: "ES", + jurisdictionRegionCode: null, + taxScope: "domestic", + invoiceNote: null, + allowedSurchargeCodes: null, + validFrom: null, + validTo: null, +}); + +export const buildTaxDefinitionFormFromEntity = (entity: TaxDefinition): TaxDefinitionForm => ({ + code: entity.code, + name: entity.name, + description: entity.description, + rate: entity.rate, + taxFamily: entity.taxFamily, + calculationBehavior: entity.calculationBehavior, + jurisdictionCountryCode: entity.jurisdictionCountryCode, + jurisdictionRegionCode: entity.jurisdictionRegionCode, + taxScope: entity.taxScope, + invoiceNote: entity.invoiceNote, + allowedSurchargeCodes: entity.allowedSurchargeCodes, + validFrom: entity.validFrom, + validTo: entity.validTo, +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.test.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.test.ts new file mode 100644 index 00000000..c6642303 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TaxDefinitionForm } from "../entities"; + +import { + buildCreateTaxDefinitionParams, + buildTaxDefinitionFormDefault, + buildUpdateTaxDefinitionByIdParams, +} from "./index"; + +const form: TaxDefinitionForm = { + ...buildTaxDefinitionFormDefault(), + code: "iva_21", + name: "IVA", + rate: 5.2, + description: null, + invoiceNote: "Nota", + allowedSurchargeCodes: [], + validFrom: "2026-01-01", +}; +test("crea params preservando rate, nullables, arrays y fechas", () => { + const result = buildCreateTaxDefinitionParams("11111111-1111-4111-8111-111111111111", form); + assert.deepEqual(result.data.rate, { value: "52", scale: "1" }); + assert.equal(result.data.description, null); + assert.deepEqual(result.data.allowed_surcharge_codes, []); + assert.equal(result.data.valid_from, "2026-01-01"); +}); +test("update envía exclusivamente campos mutables", () => { + const result = buildUpdateTaxDefinitionByIdParams("11111111-1111-4111-8111-111111111111", form); + assert.deepEqual(Object.keys(result.data).sort(), [ + "allowed_surcharge_codes", + "description", + "invoice_note", + "name", + "rate", + "valid_from", + "valid_to", + ]); + assert.equal("code" in result.data, false); + assert.equal("tax_family" in result.data, false); + assert.equal("tax_scope" in result.data, false); + assert.equal(result.data.description, null); +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.ts new file mode 100644 index 00000000..79052cfe --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definition-params.ts @@ -0,0 +1,54 @@ +import type { + CreateTaxDefinitionParams, + UpdateTaxDefinitionByIdParams, +} from "../../../tax-definitions/shared"; +import type { TaxDefinitionForm } from "../entities"; + +const rateToDto = (rate: number) => { + const [whole, decimal = ""] = String(rate).split("."); + const scale = Math.min(decimal.length, 2); + return { + value: String(Number(whole) * 10 ** scale + Number(decimal.slice(0, scale).padEnd(scale, "0"))), + scale: String(scale), + }; +}; + +export const buildCreateTaxDefinitionParams = ( + id: string, + form: TaxDefinitionForm +): CreateTaxDefinitionParams => ({ + id, + data: { + id, + code: form.code.trim(), + name: form.name.trim(), + description: form.description, + rate: rateToDto(form.rate), + tax_family: form.taxFamily, + calculation_behavior: form.calculationBehavior, + jurisdiction_country_code: form.jurisdictionCountryCode, + jurisdiction_region_code: form.jurisdictionRegionCode, + tax_scope: form.taxScope, + invoice_note: form.invoiceNote, + allowed_surcharge_codes: form.allowedSurchargeCodes, + is_active: true, + valid_from: form.validFrom, + valid_to: form.validTo, + }, +}); + +export const buildUpdateTaxDefinitionByIdParams = ( + taxDefinitionId: string, + form: TaxDefinitionForm +): UpdateTaxDefinitionByIdParams => ({ + taxDefinitionId, + data: { + name: form.name.trim(), + description: form.description, + rate: rateToDto(form.rate), + invoice_note: form.invoiceNote, + allowed_surcharge_codes: form.allowedSurchargeCodes, + valid_from: form.validFrom, + valid_to: form.validTo, + }, +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.test.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.test.ts new file mode 100644 index 00000000..d6dad739 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTaxDefinitionsListCriteria } from "./build-tax-definitions-list-criteria"; + +test("construye búsqueda, filtro activo y orden por código", () => { + assert.deepEqual(buildTaxDefinitionsListCriteria({ search: " iva ", status: "active" }), { + q: "iva", + filters: [{ field: "isActive", operator: "EQUALS", value: "true" }], + pageNumber: 0, + pageSize: 9999, + orderBy: "code", + order: "asc", + }); +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts new file mode 100644 index 00000000..e69117c6 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts @@ -0,0 +1,23 @@ +import type { CriteriaDTO } from "@erp/core"; + +export type TaxDefinitionsStatusFilter = "all" | "active" | "inactive"; + +export const buildTaxDefinitionsListCriteria = (params: { + search: string; + status: TaxDefinitionsStatusFilter; +}): CriteriaDTO => ({ + q: params.search.trim() || undefined, + filters: + params.status === "all" + ? undefined + : [{ field: "isActive", operator: "EQUALS", value: String(params.status === "active") }], + pageNumber: 0, + pageSize: 9999, + orderBy: "code", + order: "asc", +}); + +export const hasTaxDefinitionsListCriteriaApplied = (params: { + search: string; + status: TaxDefinitionsStatusFilter; +}) => params.search.trim().length > 0 || params.status !== "all"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/index.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/index.ts new file mode 100644 index 00000000..d6668977 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./build-tax-definition-form"; +export * from "./build-tax-definition-params"; +export * from "./build-tax-definitions-list-criteria"; +export * from "./tax-definition-actions"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts new file mode 100644 index 00000000..a84bd3c4 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TaxDefinitionListRow } from "../../../tax-definitions/shared"; + +import { + canDeleteTaxDefinition, + canDisableTaxDefinition, + canEditTaxDefinition, + canEnableTaxDefinition, +} from "./tax-definition-actions"; + +const system: TaxDefinitionListRow = { + id: "id", + companyId: "company", + code: "iva_21", + name: "IVA", + isActive: true, + isSystem: true, +}; +const custom: TaxDefinitionListRow = { ...system, isSystem: false }; +test("la política controller protege edit, disable y delete de system, pero permite enable", () => { + assert.equal(canEditTaxDefinition(system), false); + assert.equal(canDisableTaxDefinition(system), false); + assert.equal(canDeleteTaxDefinition(system), false); + assert.equal(canEnableTaxDefinition(system), true); + assert.equal(canEditTaxDefinition(custom), true); + assert.equal(canDisableTaxDefinition(custom), true); + assert.equal(canDeleteTaxDefinition(custom), true); +}); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.ts new file mode 100644 index 00000000..edf5a512 --- /dev/null +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.ts @@ -0,0 +1,6 @@ +import type { TaxDefinitionListRow } from "../../../tax-definitions/shared"; + +export const canEditTaxDefinition = (row: TaxDefinitionListRow) => !row.isSystem; +export const canDisableTaxDefinition = (row: TaxDefinitionListRow) => !row.isSystem; +export const canDeleteTaxDefinition = (row: TaxDefinitionListRow) => !row.isSystem; +export const canEnableTaxDefinition = (_row: TaxDefinitionListRow) => true;