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:
parent
aed24d5354
commit
52335dcbd7
@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -5,3 +5,4 @@ export {
|
||||
export * from "./payment-methods";
|
||||
export * from "./payment-terms";
|
||||
export * from "./tax-regimes";
|
||||
export * from "./settings/tax-definitions";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./payment-methods";
|
||||
export * from "./payment-terms";
|
||||
export * from "./tax-regimes";
|
||||
export * from "./tax-definitions";
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-tax-definitions-settings-controller";
|
||||
export * from "./use-tax-definitions-settings-page-controller";
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./tax-definition-form.entity";
|
||||
export * from "./tax-definition-form.schema";
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
@ -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>;
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./controllers";
|
||||
export * from "./entities";
|
||||
export * from "./ui";
|
||||
export * from "./utils";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./components";
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./tax-definitions-settings-page";
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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);
|
||||
});
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user