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:
David Arranz 2026-09-12 14:27:54 +02:00
parent 52335dcbd7
commit 9c7dfc8645
20 changed files with 947 additions and 116 deletions

View File

@ -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;
};

View File

@ -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,
};

View File

@ -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,
});

View File

@ -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"],

View File

@ -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(),
});

View File

@ -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" }
}
}

View File

@ -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" }
}
}

View File

@ -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,

View File

@ -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";

View File

@ -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>
);

View File

@ -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

View File

@ -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",
}
);
});

View File

@ -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";

View File

@ -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,
};

View File

@ -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,
},
],
});
});

View File

@ -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),
};
},
};

View File

@ -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;
}

View File

@ -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.

View File

@ -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();

View File

@ -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() {