feat(tax-definitions): enhance tax definitions settings with new filters and pagination
- Updated Spanish localization for tax definitions, adding new fields and improving structure. - Enhanced the tax definitions settings page controller to support family and scope filters, pagination, and sorting. - Introduced a new data table component for displaying tax definitions with improved UI and functionality. - Added new actions for tax definitions, including create, edit, and delete functionalities. - Updated criteria building for tax definitions to include family and scope filters, along with pagination and sorting options. - Improved tests for tax definitions to cover new features and ensure proper functionality. - Refactored recurring proforma controllers to utilize new query field guards for better validation.
This commit is contained in:
parent
52335dcbd7
commit
9c7dfc8645
@ -1,11 +1,20 @@
|
||||
import type { TaxDefinitionCode } from "@erp/catalogs/api/domain";
|
||||
import type { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "@erp/catalogs/api/domain";
|
||||
import type { TextValue, UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
export type TaxDefinitionSummary = {
|
||||
id: UniqueID;
|
||||
companyId: UniqueID;
|
||||
code: TaxDefinitionCode;
|
||||
name: TextValue;
|
||||
description: Maybe<TextValue>;
|
||||
rate: TaxRate;
|
||||
taxFamily: TaxFamily;
|
||||
jurisdictionCountryCode: string;
|
||||
jurisdictionRegionCode: Maybe<string>;
|
||||
taxScope: TaxScope;
|
||||
validFrom: Maybe<string>;
|
||||
validTo: Maybe<string>;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
};
|
||||
|
||||
@ -13,6 +13,29 @@ export class TaxDefinitionSummarySnapshotBuilder implements ITaxDefinitionSummar
|
||||
company_id: src.companyId.toString(),
|
||||
code: src.code.toPrimitive(),
|
||||
name: src.name.toPrimitive(),
|
||||
description: src.description.match(
|
||||
(value) => value.toPrimitive(),
|
||||
() => null
|
||||
),
|
||||
rate: {
|
||||
value: String(src.rate.toPrimitive().value),
|
||||
scale: String(src.rate.toPrimitive().scale),
|
||||
},
|
||||
tax_family: src.taxFamily.toPrimitive(),
|
||||
jurisdiction_country_code: src.jurisdictionCountryCode,
|
||||
jurisdiction_region_code: src.jurisdictionRegionCode.match(
|
||||
(value) => value,
|
||||
() => null
|
||||
),
|
||||
tax_scope: src.taxScope.toPrimitive(),
|
||||
valid_from: src.validFrom.match(
|
||||
(value) => value,
|
||||
() => null
|
||||
),
|
||||
valid_to: src.validTo.match(
|
||||
(value) => value,
|
||||
() => null
|
||||
),
|
||||
is_active: src.isActive,
|
||||
is_system: src.isSystem,
|
||||
};
|
||||
|
||||
@ -5,11 +5,12 @@ import {
|
||||
ValidationErrorCollection,
|
||||
type ValidationErrorDetail,
|
||||
extractOrPushError,
|
||||
maybeFromNullableResult,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { TaxDefinitionSummary } from "../../../../../application";
|
||||
import { TaxDefinitionCode } from "../../../../../domain";
|
||||
import { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "../../../../../domain";
|
||||
import type { TaxDefinitionModel } from "../models";
|
||||
|
||||
export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
|
||||
@ -26,6 +27,33 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
|
||||
const id = extractOrPushError(UniqueID.create(raw.id), "id", errors);
|
||||
const code = extractOrPushError(TaxDefinitionCode.create(raw.code), "code", errors);
|
||||
const name = extractOrPushError(TextValue.create(raw.name), "name", errors);
|
||||
const description = extractOrPushError(
|
||||
maybeFromNullableResult(raw.description, (value) => TextValue.create(value)),
|
||||
"description",
|
||||
errors
|
||||
);
|
||||
const rate = extractOrPushError(
|
||||
TaxRate.create({ value: raw.rate_value, scale: raw.rate_scale }),
|
||||
"rate",
|
||||
errors
|
||||
);
|
||||
const taxFamily = extractOrPushError(TaxFamily.create(raw.tax_family), "tax_family", errors);
|
||||
const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors);
|
||||
const jurisdictionRegionCode = extractOrPushError(
|
||||
maybeFromNullableResult(raw.jurisdiction_region_code, (value) => Result.ok(value)),
|
||||
"jurisdiction_region_code",
|
||||
errors
|
||||
);
|
||||
const validFrom = extractOrPushError(
|
||||
maybeFromNullableResult(raw.valid_from, (value) => Result.ok(String(value).slice(0, 10))),
|
||||
"valid_from",
|
||||
errors
|
||||
);
|
||||
const validTo = extractOrPushError(
|
||||
maybeFromNullableResult(raw.valid_to, (value) => Result.ok(String(value).slice(0, 10))),
|
||||
"valid_to",
|
||||
errors
|
||||
);
|
||||
|
||||
const isActive = raw.is_active;
|
||||
const isSystem = raw.is_system;
|
||||
@ -41,6 +69,14 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
|
||||
companyId: companyId!,
|
||||
code: code!,
|
||||
name: name!,
|
||||
description: description!,
|
||||
rate: rate!,
|
||||
taxFamily: taxFamily!,
|
||||
jurisdictionCountryCode: raw.jurisdiction_country_code,
|
||||
jurisdictionRegionCode: jurisdictionRegionCode!,
|
||||
taxScope: taxScope!,
|
||||
validFrom: validFrom!,
|
||||
validTo: validTo!,
|
||||
isActive,
|
||||
isSystem,
|
||||
});
|
||||
|
||||
@ -227,6 +227,14 @@ export class SequelizeTaxDefinitionRepository
|
||||
type: "root",
|
||||
column: "is_active",
|
||||
},
|
||||
taxFamily: {
|
||||
type: "root",
|
||||
column: "tax_family",
|
||||
},
|
||||
taxScope: {
|
||||
type: "root",
|
||||
column: "tax_scope",
|
||||
},
|
||||
},
|
||||
searchableFields: ["code", "name", "description", "invoice_note"],
|
||||
sortableFields: ["code", "name"],
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { IsoDateSchema, PercentageSchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
|
||||
@ -7,6 +8,30 @@ export const TaxDefinitionSummarySchema = z.object({
|
||||
company_id: z.uuid(),
|
||||
code: TaxDefinitionCodeSchema,
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
rate: PercentageSchema,
|
||||
tax_family: z.enum([
|
||||
"iva",
|
||||
"igic",
|
||||
"ipsi",
|
||||
"equivalence_surcharge",
|
||||
"withholding",
|
||||
"vat",
|
||||
"gst",
|
||||
"sales_tax",
|
||||
"reverse_charge",
|
||||
"exempt",
|
||||
"not_subject",
|
||||
"custom",
|
||||
]),
|
||||
jurisdiction_country_code: z.string().regex(/^[A-Z]{2}$/),
|
||||
jurisdiction_region_code: z
|
||||
.string()
|
||||
.regex(/^[A-Z]{2}-[A-Z0-9-]+$/)
|
||||
.nullable(),
|
||||
tax_scope: z.enum(["domestic", "intra_eu", "export", "import", "international"]),
|
||||
valid_from: IsoDateSchema.nullable(),
|
||||
valid_to: IsoDateSchema.nullable(),
|
||||
is_active: z.boolean(),
|
||||
is_system: z.boolean(),
|
||||
});
|
||||
|
||||
@ -397,36 +397,106 @@
|
||||
"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" },
|
||||
"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",
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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" },
|
||||
"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" },
|
||||
"toolbar": { "search": "Search by code, name or description...", "status": "Status" },
|
||||
"filters": {
|
||||
"taxFamily": "Tax family",
|
||||
"scope": "Scope",
|
||||
"status": "Status",
|
||||
"all": "All",
|
||||
"allFemale": "All"
|
||||
},
|
||||
"columns": {
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"rate": "Rate",
|
||||
"taxFamily": "Tax family",
|
||||
"scope": "Scope",
|
||||
"validity": "Validity",
|
||||
"status": "Status",
|
||||
"system": "System",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"system": { "system": "System", "custom": "Custom" },
|
||||
"validity": { "from": "From", "to": "To" },
|
||||
"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." },
|
||||
"empty": {
|
||||
"default": "There are no tax types.",
|
||||
"filtered": "No tax types match the current filters."
|
||||
},
|
||||
"error": { "title": "The settings could not be loaded" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,36 +397,115 @@
|
||||
"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" },
|
||||
"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",
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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" },
|
||||
"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." },
|
||||
"toolbar": { "search": "Buscar por código, nombre o descripción...", "status": "Estado" },
|
||||
"filters": {
|
||||
"taxFamily": "Familia fiscal",
|
||||
"scope": "Ámbito",
|
||||
"status": "Estado",
|
||||
"all": "Todos",
|
||||
"allFemale": "Todas"
|
||||
},
|
||||
"columns": {
|
||||
"code": "Código",
|
||||
"name": "Nombre",
|
||||
"rate": "Tipo",
|
||||
"taxFamily": "Familia fiscal",
|
||||
"scope": "Ámbito",
|
||||
"validity": "Vigencia",
|
||||
"status": "Estado",
|
||||
"system": "Sistema",
|
||||
"actions": "Acciones"
|
||||
},
|
||||
"system": { "system": "Sistema", "custom": "Personalizado" },
|
||||
"validity": { "from": "Desde", "to": "Hasta" },
|
||||
"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.",
|
||||
"filtered": "No se han encontrado tipos de impuesto con los filtros actuales."
|
||||
},
|
||||
"error": { "title": "No se pudo cargar la configuración" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { DataTableSort } from "@repo/rdx-ui/components";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTaxDefinitionListQuery } from "../../../tax-definitions/shared";
|
||||
import {
|
||||
TAX_DEFINITIONS_DEFAULT_SORT,
|
||||
type TaxDefinitionsFamilyFilter,
|
||||
type TaxDefinitionsScopeFilter,
|
||||
type TaxDefinitionsStatusFilter,
|
||||
buildTaxDefinitionsListCriteria,
|
||||
hasTaxDefinitionsListCriteriaApplied,
|
||||
@ -13,12 +17,26 @@ import { useTaxDefinitionsSettingsController } from "./use-tax-definitions-setti
|
||||
export const useTaxDefinitionsSettingsPageController = () => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<TaxDefinitionsStatusFilter>("all");
|
||||
const [family, setFamily] = useState<TaxDefinitionsFamilyFilter>("all");
|
||||
const [scope, setScope] = useState<TaxDefinitionsScopeFilter>("all");
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [sort, setSortState] = useState<DataTableSort>(TAX_DEFINITIONS_DEFAULT_SORT);
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
|
||||
const criteria = useMemo(
|
||||
() => buildTaxDefinitionsListCriteria({ search: debouncedSearch, status }),
|
||||
[debouncedSearch, status]
|
||||
() =>
|
||||
buildTaxDefinitionsListCriteria({
|
||||
search: debouncedSearch,
|
||||
status,
|
||||
family,
|
||||
scope,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
sort,
|
||||
}),
|
||||
[debouncedSearch, family, pageIndex, pageSize, scope, sort, status]
|
||||
);
|
||||
|
||||
const listQuery = useTaxDefinitionListQuery({ criteria });
|
||||
@ -30,8 +48,15 @@ export const useTaxDefinitionsSettingsPageController = () => {
|
||||
const hasAppliedCriteria = hasTaxDefinitionsListCriteriaApplied({
|
||||
search: debouncedSearch,
|
||||
status,
|
||||
family,
|
||||
scope,
|
||||
});
|
||||
|
||||
const resetPageAndSet = useCallback(<T>(setter: (value: T) => void, value: T) => {
|
||||
setter(value);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
const retry = () => {
|
||||
listQuery.refetch();
|
||||
};
|
||||
@ -45,12 +70,25 @@ export const useTaxDefinitionsSettingsPageController = () => {
|
||||
hasEmptyState: !isInitialLoading && totalItems === 0 && !hasAppliedCriteria,
|
||||
hasAppliedCriteria,
|
||||
search,
|
||||
setSearch,
|
||||
setSearch: (value: string) => resetPageAndSet(setSearch, value),
|
||||
status,
|
||||
setStatus,
|
||||
setStatus: (value: TaxDefinitionsStatusFilter) => resetPageAndSet(setStatus, value),
|
||||
family,
|
||||
setFamily: (value: TaxDefinitionsFamilyFilter) => resetPageAndSet(setFamily, value),
|
||||
scope,
|
||||
setScope: (value: TaxDefinitionsScopeFilter) => resetPageAndSet(setScope, value),
|
||||
pageIndex,
|
||||
setPageIndex,
|
||||
pageSize,
|
||||
setPageSize: (value: number) => resetPageAndSet(setPageSize, value),
|
||||
sort,
|
||||
setSort: (value: DataTableSort) => resetPageAndSet(setSortState, value),
|
||||
clearFilters: () => {
|
||||
setSearch("");
|
||||
setStatus("all");
|
||||
setFamily("all");
|
||||
setScope("all");
|
||||
setPageIndex(0);
|
||||
},
|
||||
criteria,
|
||||
pageError: listQuery.isError ? listQuery.error : null,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./delete-tax-definition-dialog";
|
||||
export * from "./tax-definition-actions";
|
||||
export * from "./tax-definition-card";
|
||||
export * from "./tax-definition-form-dialog";
|
||||
export * from "./tax-definitions-empty-state";
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import { Card, Skeleton } from "@repo/shadcn-ui/components";
|
||||
import { SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||
import { Card, CardContent, CardHeader, Skeleton } from "@repo/shadcn-ui/components";
|
||||
export const TaxDefinitionsSettingsSkeleton = () => (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
{[1, 2, 3].map((id) => (
|
||||
<Card className="p-4" key={id}>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonDataTable columns={9} rows={10} showFooter />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
type DataTableProps,
|
||||
RdxBadge,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@ -10,29 +16,264 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { PlusIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type {
|
||||
TaxDefinitionFamily,
|
||||
TaxDefinitionListRow,
|
||||
TaxScope,
|
||||
} from "../../../../tax-definitions/shared";
|
||||
import { useTaxDefinitionsSettingsPageController } from "../../controllers";
|
||||
import type { TaxDefinitionsStatusFilter } from "../../utils";
|
||||
import type {
|
||||
TaxDefinitionsFamilyFilter,
|
||||
TaxDefinitionsScopeFilter,
|
||||
TaxDefinitionsStatusFilter,
|
||||
} from "../../utils";
|
||||
import {
|
||||
DeleteTaxDefinitionDialog,
|
||||
TaxDefinitionCard,
|
||||
TaxDefinitionActions,
|
||||
TaxDefinitionFormDialog,
|
||||
TaxDefinitionsEmptyState,
|
||||
TaxDefinitionsSettingsSkeleton,
|
||||
} from "../components";
|
||||
|
||||
const TAX_FAMILIES: TaxDefinitionFamily[] = [
|
||||
"iva",
|
||||
"igic",
|
||||
"ipsi",
|
||||
"equivalence_surcharge",
|
||||
"withholding",
|
||||
"vat",
|
||||
"gst",
|
||||
"sales_tax",
|
||||
"reverse_charge",
|
||||
"exempt",
|
||||
"not_subject",
|
||||
"custom",
|
||||
];
|
||||
const TAX_SCOPES: TaxScope[] = ["domestic", "intra_eu", "export", "import", "international"];
|
||||
type TaxDefinitionColumns = DataTableProps<TaxDefinitionListRow, unknown>["columns"];
|
||||
|
||||
const formatRate = (rate: number, locale: string) =>
|
||||
new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||
rate
|
||||
);
|
||||
|
||||
const formatCivilDate = (value: string, locale: string) => {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(year, month - 1, day));
|
||||
};
|
||||
|
||||
const formatValidity = (row: TaxDefinitionListRow, locale: string, from: string, to: string) => {
|
||||
if (row.validFrom && row.validTo) {
|
||||
return `${formatCivilDate(row.validFrom, locale)} – ${formatCivilDate(row.validTo, locale)}`;
|
||||
}
|
||||
if (row.validFrom) {
|
||||
return `${from} ${formatCivilDate(row.validFrom, locale)}`;
|
||||
}
|
||||
if (row.validTo) {
|
||||
return `${to} ${formatCivilDate(row.validTo, locale)}`;
|
||||
}
|
||||
return "—";
|
||||
};
|
||||
|
||||
const getFamilyBadgeClassName = (family: TaxDefinitionFamily) => {
|
||||
if (["iva", "igic", "ipsi", "vat"].includes(family)) {
|
||||
return "bg-primary/10 text-primary";
|
||||
}
|
||||
if (["equivalence_surcharge", "withholding"].includes(family)) {
|
||||
return "bg-accent text-accent-foreground";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
||||
export const TaxDefinitionsSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const pageCtrl = useTaxDefinitionsSettingsPageController();
|
||||
const featureCtrl = pageCtrl.controller;
|
||||
const locale = i18n.language === "en" ? "en-US" : "es-ES";
|
||||
const status = (value: TaxDefinitionsStatusFilter | null) => pageCtrl.setStatus(value ?? "all");
|
||||
const family = (value: TaxDefinitionsFamilyFilter | null) => pageCtrl.setFamily(value ?? "all");
|
||||
const scope = (value: TaxDefinitionsScopeFilter | null) => pageCtrl.setScope(value ?? "all");
|
||||
|
||||
const columns = useMemo<TaxDefinitionColumns>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "code",
|
||||
enableSorting: true,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.code")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono font-medium">{row.original.code}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableSorting: true,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.name")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-40 max-w-64 whitespace-normal">
|
||||
<span className="block font-medium">{row.original.name}</span>
|
||||
{row.original.description ? (
|
||||
<span className="block truncate text-sm text-muted-foreground">
|
||||
{row.original.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.rate")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RdxBadge className="bg-muted font-medium text-foreground" variant="secondary">
|
||||
{formatRate(row.original.rate, locale)} %
|
||||
</RdxBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "taxFamily",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.taxFamily")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RdxBadge className={getFamilyBadgeClassName(row.original.taxFamily)} variant="secondary">
|
||||
{t(`settings.taxDefinitions.options.family.${row.original.taxFamily}`)}
|
||||
</RdxBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.scope")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span className="font-medium">{row.original.jurisdictionCountryCode}</span>
|
||||
{row.original.jurisdictionRegionCode ? (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{row.original.jurisdictionRegionCode}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{t(`settings.taxDefinitions.options.scope.${row.original.taxScope}`)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.validity")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{formatValidity(
|
||||
row.original,
|
||||
locale,
|
||||
t("settings.taxDefinitions.validity.from"),
|
||||
t("settings.taxDefinitions.validity.to")
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.status")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RdxBadge
|
||||
className={
|
||||
row.original.isActive
|
||||
? "bg-emerald-500/10 text-emerald-700"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
variant="secondary"
|
||||
>
|
||||
<span aria-hidden>●</span>{" "}
|
||||
{t(`settings.taxDefinitions.status.${row.original.isActive ? "active" : "inactive"}`)}
|
||||
</RdxBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={t("settings.taxDefinitions.columns.system")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RdxBadge
|
||||
className={
|
||||
row.original.isSystem
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-accent text-accent-foreground"
|
||||
}
|
||||
variant="secondary"
|
||||
>
|
||||
{t(`settings.taxDefinitions.system.${row.original.isSystem ? "system" : "custom"}`)}
|
||||
</RdxBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
isActionsColumn: true,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "flex justify-end",
|
||||
},
|
||||
header: t("settings.taxDefinitions.columns.actions"),
|
||||
cell: ({ row }) => (
|
||||
<TaxDefinitionActions
|
||||
item={row.original}
|
||||
onDelete={featureCtrl.openDeleteDialog}
|
||||
onEdit={featureCtrl.openEditDialog}
|
||||
onToggle={(item) =>
|
||||
item.isActive ? featureCtrl.disable(item) : featureCtrl.enable(item)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[featureCtrl, locale, t]
|
||||
);
|
||||
|
||||
if (pageCtrl.isInitialLoading) {
|
||||
return <TaxDefinitionsSettingsSkeleton />;
|
||||
}
|
||||
|
||||
if (pageCtrl.pageError) {
|
||||
return (
|
||||
<PageErrorState
|
||||
@ -50,55 +291,119 @@ export const TaxDefinitionsSettingsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{t("settings.taxDefinitions.title")}</h2>
|
||||
<p>{t("settings.taxDefinitions.description")}</p>
|
||||
<p className="text-muted-foreground">{t("settings.taxDefinitions.description")}</p>
|
||||
</div>
|
||||
<Button onClick={featureCtrl.openCreateDialog}>
|
||||
<Button onClick={featureCtrl.openCreateDialog} type="button">
|
||||
<PlusIcon aria-hidden className="size-4" />
|
||||
{t("settings.taxDefinitions.actions.create")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<SimpleSearchInput
|
||||
onSearchChange={pageCtrl.setSearch}
|
||||
placeholder={t("settings.taxDefinitions.toolbar.search")}
|
||||
value={pageCtrl.search}
|
||||
/>
|
||||
<Select onValueChange={status} value={pageCtrl.status}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("settings.taxDefinitions.status.all")}</SelectItem>
|
||||
<SelectItem value="active">{t("settings.taxDefinitions.status.active")}</SelectItem>
|
||||
<SelectItem value="inactive">
|
||||
{t("settings.taxDefinitions.status.inactive")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{pageCtrl.list.length ? (
|
||||
pageCtrl.list.map((item) => (
|
||||
<TaxDefinitionCard
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDelete={featureCtrl.openDeleteDialog}
|
||||
onEdit={featureCtrl.openEditDialog}
|
||||
onToggle={(row) =>
|
||||
row.isActive ? featureCtrl.disable(row) : featureCtrl.enable(row)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={pageCtrl.list}
|
||||
enablePagination
|
||||
isFetching={pageCtrl.isBackgroundFetching}
|
||||
manualPagination
|
||||
manualSorting
|
||||
onPageChange={pageCtrl.setPageIndex}
|
||||
onPageSizeChange={pageCtrl.setPageSize}
|
||||
onSortChange={pageCtrl.setSort}
|
||||
pageIndex={pageCtrl.pageIndex}
|
||||
pageSize={pageCtrl.pageSize}
|
||||
readOnly
|
||||
sort={pageCtrl.sort}
|
||||
topContent={
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-64 flex-1 basis-72">
|
||||
<SimpleSearchInput
|
||||
onSearchChange={pageCtrl.setSearch}
|
||||
placeholder={t("settings.taxDefinitions.toolbar.search")}
|
||||
value={pageCtrl.search}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-44 gap-1.5 text-sm font-medium">
|
||||
<span id="tax-definition-family-filter-label">
|
||||
{t("settings.taxDefinitions.filters.taxFamily")}
|
||||
</span>
|
||||
<Select onValueChange={family} value={pageCtrl.family}>
|
||||
<SelectTrigger aria-labelledby="tax-definition-family-filter-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("settings.taxDefinitions.filters.allFemale")}
|
||||
</SelectItem>
|
||||
{TAX_FAMILIES.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{t(`settings.taxDefinitions.options.family.${item}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid min-w-44 gap-1.5 text-sm font-medium">
|
||||
<span id="tax-definition-scope-filter-label">
|
||||
{t("settings.taxDefinitions.filters.scope")}
|
||||
</span>
|
||||
<Select onValueChange={scope} value={pageCtrl.scope}>
|
||||
<SelectTrigger aria-labelledby="tax-definition-scope-filter-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("settings.taxDefinitions.filters.all")}
|
||||
</SelectItem>
|
||||
{TAX_SCOPES.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{t(`settings.taxDefinitions.options.scope.${item}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid min-w-40 gap-1.5 text-sm font-medium">
|
||||
<span id="tax-definition-status-filter-label">
|
||||
{t("settings.taxDefinitions.filters.status")}
|
||||
</span>
|
||||
<Select onValueChange={status} value={pageCtrl.status}>
|
||||
<SelectTrigger aria-labelledby="tax-definition-status-filter-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("settings.taxDefinitions.status.all")}</SelectItem>
|
||||
<SelectItem value="active">
|
||||
{t("settings.taxDefinitions.status.active")}
|
||||
</SelectItem>
|
||||
<SelectItem value="inactive">
|
||||
{t("settings.taxDefinitions.status.inactive")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!pageCtrl.hasAppliedCriteria}
|
||||
onClick={pageCtrl.clearFilters}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcwIcon aria-hidden className="size-4" />
|
||||
{t("settings.taxDefinitions.actions.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
totalItems={pageCtrl.totalItems}
|
||||
/>
|
||||
{pageCtrl.totalItems === 0 ? (
|
||||
<TaxDefinitionsEmptyState
|
||||
filtered={pageCtrl.hasAppliedCriteria}
|
||||
onClear={pageCtrl.clearFilters}
|
||||
onCreate={featureCtrl.openCreateDialog}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TaxDefinitionFormDialog
|
||||
|
||||
@ -3,13 +3,28 @@ 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",
|
||||
});
|
||||
test("construye búsqueda, filtros y orden server-side", () => {
|
||||
assert.deepEqual(
|
||||
buildTaxDefinitionsListCriteria({
|
||||
search: " iva ",
|
||||
status: "active",
|
||||
family: "iva",
|
||||
scope: "domestic",
|
||||
pageIndex: 2,
|
||||
pageSize: 25,
|
||||
sort: { field: "name", direction: "desc" },
|
||||
}),
|
||||
{
|
||||
q: "iva",
|
||||
filters: [
|
||||
{ field: "isActive", operator: "EQUALS", value: "true" },
|
||||
{ field: "taxFamily", operator: "EQUALS", value: "iva" },
|
||||
{ field: "taxScope", operator: "EQUALS", value: "domestic" },
|
||||
],
|
||||
pageNumber: 2,
|
||||
pageSize: 25,
|
||||
orderBy: "name",
|
||||
order: "desc",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,23 +1,61 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import type { DataTableSort } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { TaxDefinitionFamily, TaxScope } from "../../../tax-definitions/shared";
|
||||
|
||||
export type TaxDefinitionsStatusFilter = "all" | "active" | "inactive";
|
||||
export type TaxDefinitionsFamilyFilter = "all" | TaxDefinitionFamily;
|
||||
export type TaxDefinitionsScopeFilter = "all" | TaxScope;
|
||||
|
||||
const DEFAULT_SORT = { field: "code", direction: "asc" } satisfies DataTableSort;
|
||||
|
||||
const SORT_FIELDS = {
|
||||
code: "code",
|
||||
name: "name",
|
||||
} as const;
|
||||
|
||||
export const TAX_DEFINITIONS_DEFAULT_SORT = DEFAULT_SORT;
|
||||
|
||||
export const buildTaxDefinitionsListCriteria = (params: {
|
||||
search: string;
|
||||
status: TaxDefinitionsStatusFilter;
|
||||
family: TaxDefinitionsFamilyFilter;
|
||||
scope: TaxDefinitionsScopeFilter;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
sort: DataTableSort;
|
||||
}): 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",
|
||||
filters: [
|
||||
...(params.status === "all"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
field: "isActive",
|
||||
operator: "EQUALS" as const,
|
||||
value: String(params.status === "active"),
|
||||
},
|
||||
]),
|
||||
...(params.family === "all"
|
||||
? []
|
||||
: [{ field: "taxFamily", operator: "EQUALS" as const, value: params.family }]),
|
||||
...(params.scope === "all"
|
||||
? []
|
||||
: [{ field: "taxScope", operator: "EQUALS" as const, value: params.scope }]),
|
||||
],
|
||||
pageNumber: params.pageIndex,
|
||||
pageSize: params.pageSize,
|
||||
orderBy: SORT_FIELDS[params.sort.field as keyof typeof SORT_FIELDS] ?? DEFAULT_SORT.field,
|
||||
order: params.sort.direction,
|
||||
});
|
||||
|
||||
export const hasTaxDefinitionsListCriteriaApplied = (params: {
|
||||
search: string;
|
||||
status: TaxDefinitionsStatusFilter;
|
||||
}) => params.search.trim().length > 0 || params.status !== "all";
|
||||
family: TaxDefinitionsFamilyFilter;
|
||||
scope: TaxDefinitionsScopeFilter;
|
||||
}) =>
|
||||
params.search.trim().length > 0 ||
|
||||
params.status !== "all" ||
|
||||
params.family !== "all" ||
|
||||
params.scope !== "all";
|
||||
|
||||
@ -15,6 +15,14 @@ const system: TaxDefinitionListRow = {
|
||||
companyId: "company",
|
||||
code: "iva_21",
|
||||
name: "IVA",
|
||||
description: null,
|
||||
rate: 21,
|
||||
taxFamily: "iva",
|
||||
jurisdictionCountryCode: "ES",
|
||||
jurisdictionRegionCode: null,
|
||||
taxScope: "domestic",
|
||||
validFrom: null,
|
||||
validTo: null,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
};
|
||||
|
||||
@ -1,23 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ListTaxDefinitionsResponseDTO } from "../../../../common";
|
||||
|
||||
import { ListTaxDefinitionsAdapter } from "./list-tax-definitions.adapter";
|
||||
|
||||
test("adapta paginación y filas ligeras preservando flags", () => {
|
||||
const dto: ListTaxDefinitionsResponseDTO = {
|
||||
page: 1, per_page: 25, total_pages: 3, total_items: 52,
|
||||
metadata: { entity: "tax_definitions", criteria: { q: "iva", filters: [], orderBy: "code", orderType: "ASC", pageSize: 25, pageNumber: 1 } },
|
||||
page: 1,
|
||||
per_page: 25,
|
||||
total_pages: 3,
|
||||
total_items: 52,
|
||||
metadata: {
|
||||
entity: "tax_definitions",
|
||||
criteria: {
|
||||
q: "iva",
|
||||
filters: [],
|
||||
orderBy: "code",
|
||||
orderType: "ASC",
|
||||
pageSize: 25,
|
||||
pageNumber: 1,
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{ id: "11111111-1111-4111-8111-111111111111", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_21", name: "IVA 21", is_active: true, is_system: false },
|
||||
{ id: "33333333-3333-4333-8333-333333333333", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_10", name: "IVA 10", is_active: false, is_system: true },
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
company_id: "22222222-2222-4222-8222-222222222222",
|
||||
code: "iva_21",
|
||||
name: "IVA 21",
|
||||
description: "General",
|
||||
rate: { value: "2100", scale: "2" },
|
||||
tax_family: "iva",
|
||||
jurisdiction_country_code: "ES",
|
||||
jurisdiction_region_code: null,
|
||||
tax_scope: "domestic",
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
is_active: true,
|
||||
is_system: false,
|
||||
},
|
||||
{
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
company_id: "22222222-2222-4222-8222-222222222222",
|
||||
code: "iva_10",
|
||||
name: "IVA 10",
|
||||
description: null,
|
||||
rate: { value: "1000", scale: "2" },
|
||||
tax_family: "iva",
|
||||
jurisdiction_country_code: "ES",
|
||||
jurisdiction_region_code: null,
|
||||
tax_scope: "domestic",
|
||||
valid_from: "2024-01-01",
|
||||
valid_to: "2025-12-31",
|
||||
is_active: false,
|
||||
is_system: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(ListTaxDefinitionsAdapter.fromDto(dto), {
|
||||
page: 1, perPage: 25, totalPages: 3, totalItems: 52,
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
totalPages: 3,
|
||||
totalItems: 52,
|
||||
items: [
|
||||
{ id: dto.items[0].id, companyId: dto.items[0].company_id, code: "iva_21", name: "IVA 21", isActive: true, isSystem: false },
|
||||
{ id: dto.items[1].id, companyId: dto.items[1].company_id, code: "iva_10", name: "IVA 10", isActive: false, isSystem: true },
|
||||
{
|
||||
id: dto.items[0].id,
|
||||
companyId: dto.items[0].company_id,
|
||||
code: "iva_21",
|
||||
name: "IVA 21",
|
||||
description: "General",
|
||||
rate: 21,
|
||||
taxFamily: "iva",
|
||||
jurisdictionCountryCode: "ES",
|
||||
jurisdictionRegionCode: null,
|
||||
taxScope: "domestic",
|
||||
validFrom: null,
|
||||
validTo: null,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
{
|
||||
id: dto.items[1].id,
|
||||
companyId: dto.items[1].company_id,
|
||||
code: "iva_10",
|
||||
name: "IVA 10",
|
||||
description: null,
|
||||
rate: 10,
|
||||
taxFamily: "iva",
|
||||
jurisdictionCountryCode: "ES",
|
||||
jurisdictionRegionCode: null,
|
||||
taxScope: "domestic",
|
||||
validFrom: "2024-01-01",
|
||||
validTo: "2025-12-31",
|
||||
isActive: false,
|
||||
isSystem: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Percentage } from "@repo/rdx-ddd";
|
||||
|
||||
import type { ListTaxDefinitionsResponseDTO } from "../../../../common";
|
||||
import type { ListTaxDefinitionsResult } from "../api";
|
||||
import type { TaxDefinitionList, TaxDefinitionListRow } from "../entities";
|
||||
@ -6,12 +8,38 @@ type TaxDefinitionListItemDTO = ListTaxDefinitionsResponseDTO["items"][number];
|
||||
|
||||
const TaxDefinitionListRowAdapter = {
|
||||
fromDto(dto: TaxDefinitionListItemDTO): TaxDefinitionListRow {
|
||||
return { id: dto.id, companyId: dto.company_id, code: dto.code, name: dto.name, isSystem: dto.is_system, isActive: dto.is_active };
|
||||
const rate = Percentage.fromObjectString(dto.rate);
|
||||
if (rate.isFailure) {
|
||||
throw rate.error;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
companyId: dto.company_id,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
rate: rate.data.toNumber(),
|
||||
taxFamily: dto.tax_family,
|
||||
jurisdictionCountryCode: dto.jurisdiction_country_code,
|
||||
jurisdictionRegionCode: dto.jurisdiction_region_code,
|
||||
taxScope: dto.tax_scope,
|
||||
validFrom: dto.valid_from,
|
||||
validTo: dto.valid_to,
|
||||
isSystem: dto.is_system,
|
||||
isActive: dto.is_active,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const ListTaxDefinitionsAdapter = {
|
||||
fromDto(dto: ListTaxDefinitionsResult): TaxDefinitionList {
|
||||
return { page: dto.page, perPage: dto.per_page, totalPages: dto.total_pages, totalItems: dto.total_items, items: dto.items.map(TaxDefinitionListRowAdapter.fromDto) };
|
||||
return {
|
||||
page: dto.page,
|
||||
perPage: dto.per_page,
|
||||
totalPages: dto.total_pages,
|
||||
totalItems: dto.total_items,
|
||||
items: dto.items.map(TaxDefinitionListRowAdapter.fromDto),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import type { TaxDefinitionFamily, TaxScope } from "./tax-definition.entity";
|
||||
|
||||
export interface TaxDefinitionListRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rate: number;
|
||||
taxFamily: TaxDefinitionFamily;
|
||||
jurisdictionCountryCode: string;
|
||||
jurisdictionRegionCode: string | null;
|
||||
taxScope: TaxScope;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@ -66,6 +66,19 @@ export function forbidQueryFieldGuard(field = "companyId"): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante para controllers que ejecutan guards mediante ExpressController.
|
||||
* Evita invocar el siguiente middleware mientras el controller aún está validando la petición.
|
||||
*/
|
||||
export function forbidQueryFieldControllerGuard(field = "companyId"): GuardFn {
|
||||
return ({ req }) => {
|
||||
if (Object.hasOwn(req.query ?? {}, field)) {
|
||||
return guardFail(new ValidationApiError(`Query param "${field}" is not allowed`));
|
||||
}
|
||||
return guardOk();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
Inyecta el tenant del usuario en el body (create/update).
|
||||
Ignora cualquier valor que venga del cliente.
|
||||
|
||||
@ -10,10 +10,52 @@ import {
|
||||
CompleteRecurringProformaController,
|
||||
DeleteRecurringProformaController,
|
||||
DuplicateRecurringProformaController,
|
||||
ListRecurringProformasController,
|
||||
PauseRecurringProformaController,
|
||||
ResumeRecurringProformaController,
|
||||
} from "./recurring-proformas-read.controllers";
|
||||
|
||||
test("list executes controller guards and delegates a valid query", async () => {
|
||||
const companyId = UniqueID.generateNewID();
|
||||
const listCalls: Array<{ companyId: typeof companyId }> = [];
|
||||
let statusCode: number | undefined;
|
||||
let body: unknown;
|
||||
const controller = new ListRecurringProformasController(
|
||||
async (input: { companyId: typeof companyId }) => {
|
||||
listCalls.push(input);
|
||||
return Result.ok({ page: 1, perPage: 20, totalItems: 0, totalPages: 0, items: [] });
|
||||
}
|
||||
);
|
||||
const req = {
|
||||
user: { companyId },
|
||||
query: {},
|
||||
originalUrl: "/recurring-proformas",
|
||||
method: "GET",
|
||||
protocol: "http",
|
||||
get: () => undefined,
|
||||
};
|
||||
const res = {
|
||||
status: (value: number) => {
|
||||
statusCode = value;
|
||||
return res;
|
||||
},
|
||||
json: (value: unknown) => {
|
||||
body = value;
|
||||
return res;
|
||||
},
|
||||
sendStatus: () => res,
|
||||
type: () => res,
|
||||
setHeader: () => undefined,
|
||||
};
|
||||
|
||||
await controller.execute(req as never, res as never, () => undefined);
|
||||
|
||||
assert.equal(listCalls.length, 1);
|
||||
assert.equal(listCalls[0]?.companyId.equals(companyId), true);
|
||||
assert.equal(statusCode, 200);
|
||||
assert.deepEqual(body, { page: 1, per_page: 20, total_items: 0, total_pages: 0, items: [] });
|
||||
});
|
||||
|
||||
test("archive success delegates once and returns HTTP success", async () => {
|
||||
const companyId = UniqueID.generateNewID();
|
||||
const recurringProformaId = UniqueID.generateNewID();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
forbidQueryFieldControllerGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
@ -58,7 +58,7 @@ abstract class RecurringProformaController extends ExpressController {
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
forbidQueryFieldControllerGuard("companyId")
|
||||
);
|
||||
}
|
||||
protected company() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user