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.
This commit is contained in:
David Arranz 2026-09-11 14:39:06 +02:00
parent aed24d5354
commit 52335dcbd7
34 changed files with 1289 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -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: <TaxRegimesSettingsPage />,
},
{
path: "settings/general/tax-definitions",
handle: {
layout: "app-sidebar",
protected: true,
},
element: <TaxDefinitionsSettingsPage />,
},
];
};

View File

@ -5,3 +5,4 @@ export {
export * from "./payment-methods";
export * from "./payment-terms";
export * from "./tax-regimes";
export * from "./settings/tax-definitions";

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string | null>(null);
const [deletingTaxDefinition, setDeletingTaxDefinition] = useState<TaxDefinitionListRow | null>(
null
);
const [lastError, setLastError] = useState<Error | null>(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<TaxDefinitionForm>({
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<HTMLFormElement>) => {
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,
};
};

View File

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

View File

@ -0,0 +1,2 @@
export * from "./tax-definition-form.entity";
export * from "./tax-definition-form.schema";

View File

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

View File

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

View File

@ -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<typeof TaxDefinitionFormSchema>;

View File

@ -0,0 +1,4 @@
export * from "./controllers";
export * from "./entities";
export * from "./ui";
export * from "./utils";

View File

@ -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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.taxDefinitions.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.taxDefinitions.delete.description", {
code: target?.code ?? "",
name: target?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
{t("settings.taxDefinitions.actions.cancel")}
</Button>
<Button
disabled={!target || isSubmitting}
onClick={onConfirm}
type="button"
variant="destructive"
>
{t("settings.taxDefinitions.actions.delete")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

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

View File

@ -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: <PencilIcon aria-hidden className="size-4" />,
disabled: !canEditTaxDefinition(item),
onSelect: () => onEdit(item),
},
{
id: "toggle",
label: item.isActive
? t("settings.taxDefinitions.actions.disable")
: t("settings.taxDefinitions.actions.enable"),
icon: item.isActive ? (
<PowerOffIcon aria-hidden className="size-4" />
) : (
<PowerIcon aria-hidden className="size-4" />
),
disabled: item.isActive && !canDisableTaxDefinition(item),
onSelect: () => onToggle(item),
},
];
return (
<RdxRowActions
overflowActions={[
{
id: "delete",
label: t("settings.taxDefinitions.actions.delete"),
icon: <Trash2Icon aria-hidden className="size-4" />,
destructive: true,
disabled: !canDeleteTaxDefinition(item),
onSelect: () => onDelete(item),
},
]}
overflowLabel={t("settings.taxDefinitions.actions.more")}
primaryActions={actions}
/>
);
};

View File

@ -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 (
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="min-w-0 flex-1">
<p className="font-mono font-semibold">{item.code}</p>
<p className="truncate text-sm text-muted-foreground">{item.name}</p>
</div>
<RdxBadge variant={item.isActive ? "secondary" : "destructive"}>
{item.isActive
? t("settings.taxDefinitions.status.active")
: t("settings.taxDefinitions.status.inactive")}
</RdxBadge>
{item.isSystem && (
<RdxBadge variant="secondary">{t("settings.taxDefinitions.status.system")}</RdxBadge>
)}
<TaxDefinitionActions item={item} onDelete={onDelete} onEdit={onEdit} onToggle={onToggle} />
</CardContent>
</Card>
);
};

View File

@ -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;
}) => (
<section className="rounded-xl border bg-muted/20 p-4 sm:p-5">
<div className="mb-4 flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon aria-hidden className="size-4" />
</span>
<h3 className="font-medium text-sm">{title}</h3>
</div>
{children}
</section>
);
export const TaxDefinitionFormDialog = ({
open,
onOpenChange,
form,
formId,
onSubmit,
isSubmitting,
isEditMode,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
form: UseFormReturn<TaxDefinitionForm>;
formId: string;
onSubmit: FormEventHandler<HTMLFormElement>;
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 (
<Dialog onOpenChange={(nextOpen) => !isSubmitting && onOpenChange(nextOpen)} open={open}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-3xl">
<DialogHeader className="border-b bg-muted/40 px-6 py-5 sm:px-7">
<DialogTitle>
{t(
isEditMode
? "settings.taxDefinitions.edit.title"
: "settings.taxDefinitions.create.title"
)}
</DialogTitle>
<DialogDescription>
{t(
isEditMode
? "settings.taxDefinitions.edit.description"
: "settings.taxDefinitions.create.description"
)}
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form className="space-y-5 px-6 py-5 sm:px-7" id={formId} noValidate onSubmit={onSubmit}>
<Section icon={ReceiptTextIcon} title={t("settings.taxDefinitions.form.identity")}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-2">
<TextField
autoComplete="off"
className="w-full"
disabled={immutable}
label={t("settings.taxDefinitions.fields.code")}
name="code"
placeholder="iva_21"
/>
<TextField
autoComplete="off"
className="w-full"
disabled={isSubmitting}
label={t("settings.taxDefinitions.fields.name")}
name="name"
placeholder={t("settings.taxDefinitions.fields.name")}
/>
<TextField
className="w-full"
disabled={isSubmitting}
inputClassName="tabular-nums"
inputMode="decimal"
label={t("settings.taxDefinitions.fields.rate")}
name="rate"
rightIcon={<span className="text-muted-foreground">%</span>}
typePreset="number"
/>
<SelectField<TaxDefinitionForm>
disabled={immutable}
items={families}
label={t("settings.taxDefinitions.fields.taxFamily")}
name="taxFamily"
/>
</RdxFormGrid>
</Section>
<Section icon={Settings2Icon} title={t("settings.taxDefinitions.form.calculation")}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-2">
<SelectField<TaxDefinitionForm>
disabled={immutable}
items={behaviors}
label={t("settings.taxDefinitions.fields.calculationBehavior")}
name="calculationBehavior"
/>
<SelectField<TaxDefinitionForm>
disabled={immutable}
items={scopes}
label={t("settings.taxDefinitions.fields.taxScope")}
name="taxScope"
/>
<SelectField<TaxDefinitionForm>
disabled={immutable}
items={countries}
label={t("settings.taxDefinitions.fields.country")}
name="jurisdictionCountryCode"
/>
<TextField
className="w-full"
disabled={immutable}
label={t("settings.taxDefinitions.fields.region")}
name="jurisdictionRegionCode"
placeholder="ES-M"
/>
</RdxFormGrid>
</Section>
<Section icon={CalendarDaysIcon} title={t("settings.taxDefinitions.form.validity")}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-2">
<DatePickerField<TaxDefinitionForm>
className="w-full"
disabled={isSubmitting}
label={t("settings.taxDefinitions.fields.validFrom")}
name="validFrom"
/>
<DatePickerField<TaxDefinitionForm>
className="w-full"
disabled={isSubmitting}
label={t("settings.taxDefinitions.fields.validTo")}
name="validTo"
/>
</RdxFormGrid>
</Section>
<Section icon={LandmarkIcon} title={t("settings.taxDefinitions.form.texts")}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-2">
<TextAreaField
className="w-full"
disabled={isSubmitting}
label={t("settings.taxDefinitions.fields.description")}
name="description"
rows={4}
/>
<TextAreaField
className="w-full"
disabled={isSubmitting}
label={t("settings.taxDefinitions.fields.invoiceNote")}
name="invoiceNote"
rows={4}
/>
</RdxFormGrid>
</Section>
</form>
</FormProvider>
<DialogFooter className="border-t bg-muted/30 px-6 py-4 sm:px-7">
<Button
disabled={isSubmitting}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
{t("settings.taxDefinitions.actions.cancel")}
</Button>
<Button disabled={isSubmitting} form={formId} type="submit">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("settings.taxDefinitions.actions.save")}
</>
) : (
t(
isEditMode
? "settings.taxDefinitions.actions.save"
: "settings.taxDefinitions.actions.create"
)
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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 (
<div className="py-12 text-center">
<p className="text-muted-foreground">
{filtered
? t("settings.taxDefinitions.empty.filtered")
: t("settings.taxDefinitions.empty.default")}
</p>
<Button className="mt-4" onClick={filtered ? onClear : onCreate}>
{filtered
? t("settings.taxDefinitions.actions.clearFilters")
: t("settings.taxDefinitions.actions.create")}
</Button>
</div>
);
};

View File

@ -0,0 +1,11 @@
import { Card, 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>
);

View File

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

View File

@ -0,0 +1 @@
export * from "./tax-definitions-settings-page";

View File

@ -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 <TaxDefinitionsSettingsSkeleton />;
}
if (pageCtrl.pageError) {
return (
<PageErrorState
description={pageCtrl.pageError.message}
primaryAction={{
label: t("settings.taxDefinitions.actions.retry"),
onClick: pageCtrl.retry,
}}
title={t("settings.taxDefinitions.error.title")}
variant="unknown"
/>
);
}
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">{t("settings.taxDefinitions.title")}</h2>
<p>{t("settings.taxDefinitions.description")}</p>
</div>
<Button onClick={featureCtrl.openCreateDialog}>
<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)
}
/>
))
) : (
<TaxDefinitionsEmptyState
filtered={pageCtrl.hasAppliedCriteria}
onClear={pageCtrl.clearFilters}
onCreate={featureCtrl.openCreateDialog}
/>
)}
</CardContent>
</Card>
<TaxDefinitionFormDialog
form={featureCtrl.form}
formId={featureCtrl.formId}
isEditMode={false}
isSubmitting={featureCtrl.isSaving}
onOpenChange={(open) => !open && featureCtrl.closeCreateDialog()}
onSubmit={featureCtrl.onSubmit}
open={featureCtrl.isCreateOpen}
/>
<DeleteTaxDefinitionDialog
isSubmitting={featureCtrl.isDeleting}
onConfirm={featureCtrl.confirmDelete}
onOpenChange={(open) => !open && featureCtrl.closeDeleteDialog()}
open={Boolean(featureCtrl.deletingTaxDefinition)}
target={featureCtrl.deletingTaxDefinition}
/>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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