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;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
const collection = found.data.map((item) => this.deps.summarySnapshotBuilder.toOutput(item));
|
const taxDefinitions = found.data;
|
||||||
return Result.ok(collection);
|
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."
|
"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."
|
"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[] => {
|
export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -45,5 +51,13 @@ export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[]
|
|||||||
},
|
},
|
||||||
element: <TaxRegimesSettingsPage />,
|
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-methods";
|
||||||
export * from "./payment-terms";
|
export * from "./payment-terms";
|
||||||
export * from "./tax-regimes";
|
export * from "./tax-regimes";
|
||||||
|
export * from "./settings/tax-definitions";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { CalendarClockIcon, CreditCardIcon, LandmarkIcon } from "lucide-react";
|
import { CalendarClockIcon, CreditCardIcon, LandmarkIcon, ReceiptTextIcon } from "lucide-react";
|
||||||
|
|
||||||
import { CatalogsModuleManifest } from "./manifest";
|
import { CatalogsModuleManifest } from "./manifest";
|
||||||
|
|
||||||
@ -29,6 +29,13 @@ test("publica la entry de Settings para payment methods", () => {
|
|||||||
labelKey: "settings.taxRegimes.title",
|
labelKey: "settings.taxRegimes.title",
|
||||||
path: "/settings/general/tax-regimes",
|
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?.layout, "app-sidebar");
|
||||||
assert.equal(settingsRoute?.handle?.protected, true);
|
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 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 { CatalogsRoutes } from "./catalogs-routes";
|
||||||
import { ensureModuleTranslations } from "./i18n";
|
import { ensureModuleTranslations } from "./i18n";
|
||||||
@ -38,6 +38,13 @@ export const CatalogsModuleManifest: IModuleClient = {
|
|||||||
labelKey: "settings.taxRegimes.title",
|
labelKey: "settings.taxRegimes.title",
|
||||||
path: "/settings/general/tax-regimes",
|
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),
|
routes: (params) => CatalogsRoutes(params),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./payment-methods";
|
export * from "./payment-methods";
|
||||||
export * from "./payment-terms";
|
export * from "./payment-terms";
|
||||||
export * from "./tax-regimes";
|
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