diff --git a/modules/catalogs/src/api/application/tax-definitions/models/tax-definition-summary.model.ts b/modules/catalogs/src/api/application/tax-definitions/models/tax-definition-summary.model.ts index 51e9d319..cc2a4b68 100644 --- a/modules/catalogs/src/api/application/tax-definitions/models/tax-definition-summary.model.ts +++ b/modules/catalogs/src/api/application/tax-definitions/models/tax-definition-summary.model.ts @@ -1,11 +1,20 @@ -import type { TaxDefinitionCode } from "@erp/catalogs/api/domain"; +import type { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "@erp/catalogs/api/domain"; import type { TextValue, UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; export type TaxDefinitionSummary = { id: UniqueID; companyId: UniqueID; code: TaxDefinitionCode; name: TextValue; + description: Maybe; + rate: TaxRate; + taxFamily: TaxFamily; + jurisdictionCountryCode: string; + jurisdictionRegionCode: Maybe; + taxScope: TaxScope; + validFrom: Maybe; + validTo: Maybe; isActive: boolean; isSystem: boolean; }; diff --git a/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/summary/summary-tax-definition-snapshot.builder.ts b/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/summary/summary-tax-definition-snapshot.builder.ts index 9d25551a..97517e0f 100644 --- a/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/summary/summary-tax-definition-snapshot.builder.ts +++ b/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/summary/summary-tax-definition-snapshot.builder.ts @@ -13,6 +13,29 @@ export class TaxDefinitionSummarySnapshotBuilder implements ITaxDefinitionSummar company_id: src.companyId.toString(), code: src.code.toPrimitive(), name: src.name.toPrimitive(), + description: src.description.match( + (value) => value.toPrimitive(), + () => null + ), + rate: { + value: String(src.rate.toPrimitive().value), + scale: String(src.rate.toPrimitive().scale), + }, + tax_family: src.taxFamily.toPrimitive(), + jurisdiction_country_code: src.jurisdictionCountryCode, + jurisdiction_region_code: src.jurisdictionRegionCode.match( + (value) => value, + () => null + ), + tax_scope: src.taxScope.toPrimitive(), + valid_from: src.validFrom.match( + (value) => value, + () => null + ), + valid_to: src.validTo.match( + (value) => value, + () => null + ), is_active: src.isActive, is_system: src.isSystem, }; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-summary.mapper.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-summary.mapper.ts index 38f4c1ab..68295c3d 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-summary.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-summary.mapper.ts @@ -5,11 +5,12 @@ import { ValidationErrorCollection, type ValidationErrorDetail, extractOrPushError, + maybeFromNullableResult, } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { TaxDefinitionSummary } from "../../../../../application"; -import { TaxDefinitionCode } from "../../../../../domain"; +import { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "../../../../../domain"; import type { TaxDefinitionModel } from "../models"; export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper< @@ -26,6 +27,33 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper< const id = extractOrPushError(UniqueID.create(raw.id), "id", errors); const code = extractOrPushError(TaxDefinitionCode.create(raw.code), "code", errors); const name = extractOrPushError(TextValue.create(raw.name), "name", errors); + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (value) => TextValue.create(value)), + "description", + errors + ); + const rate = extractOrPushError( + TaxRate.create({ value: raw.rate_value, scale: raw.rate_scale }), + "rate", + errors + ); + const taxFamily = extractOrPushError(TaxFamily.create(raw.tax_family), "tax_family", errors); + const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors); + const jurisdictionRegionCode = extractOrPushError( + maybeFromNullableResult(raw.jurisdiction_region_code, (value) => Result.ok(value)), + "jurisdiction_region_code", + errors + ); + const validFrom = extractOrPushError( + maybeFromNullableResult(raw.valid_from, (value) => Result.ok(String(value).slice(0, 10))), + "valid_from", + errors + ); + const validTo = extractOrPushError( + maybeFromNullableResult(raw.valid_to, (value) => Result.ok(String(value).slice(0, 10))), + "valid_to", + errors + ); const isActive = raw.is_active; const isSystem = raw.is_system; @@ -41,6 +69,14 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper< companyId: companyId!, code: code!, name: name!, + description: description!, + rate: rate!, + taxFamily: taxFamily!, + jurisdictionCountryCode: raw.jurisdiction_country_code, + jurisdictionRegionCode: jurisdictionRegionCode!, + taxScope: taxScope!, + validFrom: validFrom!, + validTo: validTo!, isActive, isSystem, }); diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts index 3bb70092..75b23884 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts @@ -227,6 +227,14 @@ export class SequelizeTaxDefinitionRepository type: "root", column: "is_active", }, + taxFamily: { + type: "root", + column: "tax_family", + }, + taxScope: { + type: "root", + column: "tax_scope", + }, }, searchableFields: ["code", "name", "description", "invoice_note"], sortableFields: ["code", "name"], diff --git a/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts index 8973a5aa..6a7c1756 100644 --- a/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts +++ b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts @@ -1,3 +1,4 @@ +import { IsoDateSchema, PercentageSchema } from "@erp/core"; import { z } from "zod/v4"; const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); @@ -7,6 +8,30 @@ export const TaxDefinitionSummarySchema = z.object({ company_id: z.uuid(), code: TaxDefinitionCodeSchema, name: z.string(), + description: z.string().nullable(), + rate: PercentageSchema, + tax_family: z.enum([ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", + ]), + jurisdiction_country_code: z.string().regex(/^[A-Z]{2}$/), + jurisdiction_region_code: z + .string() + .regex(/^[A-Z]{2}-[A-Z0-9-]+$/) + .nullable(), + tax_scope: z.enum(["domestic", "intra_eu", "export", "import", "international"]), + valid_from: IsoDateSchema.nullable(), + valid_to: IsoDateSchema.nullable(), is_active: z.boolean(), is_system: z.boolean(), }); diff --git a/modules/catalogs/src/common/locales/en.json b/modules/catalogs/src/common/locales/en.json index 79cfe20f..8cdb17fd 100644 --- a/modules/catalogs/src/common/locales/en.json +++ b/modules/catalogs/src/common/locales/en.json @@ -397,36 +397,106 @@ "taxDefinitions": { "title": "Tax types", "description": "Manage the tax types available for the company.", - "form": { "identity": "Tax identification", "calculation": "Calculation and jurisdiction", "validity": "Validity period", "texts": "Document texts" }, + "form": { + "identity": "Tax identification", + "calculation": "Calculation and jurisdiction", + "validity": "Validity period", + "texts": "Document texts" + }, "fields": { - "code": "Code", "name": "Name", "rate": "Rate (%)", - "taxFamily": "Tax family", "calculationBehavior": "Calculation", - "taxScope": "Scope", "country": "Country", "region": "Region", - "validFrom": "Valid from", "validTo": "Valid to", - "description": "Description", "invoiceNote": "Invoice note", + "code": "Code", + "name": "Name", + "rate": "Rate (%)", + "taxFamily": "Tax family", + "calculationBehavior": "Calculation", + "taxScope": "Scope", + "country": "Country", + "region": "Region", + "validFrom": "Valid from", + "validTo": "Valid to", + "description": "Description", + "invoiceNote": "Invoice note", "allowedSurchargeCodes": "Allowed surcharges" }, "options": { "family": { - "iva": "VAT", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Equivalence surcharge", "withholding": "Withholding", "vat": "International VAT", "gst": "Goods and services tax", "sales_tax": "Sales tax", "reverse_charge": "Reverse charge", "exempt": "Exempt", "not_subject": "Not subject", "custom": "Custom" + "iva": "VAT", + "igic": "IGIC", + "ipsi": "IPSI", + "equivalence_surcharge": "Equivalence surcharge", + "withholding": "Withholding", + "vat": "International VAT", + "gst": "Goods and services tax", + "sales_tax": "Sales tax", + "reverse_charge": "Reverse charge", + "exempt": "Exempt", + "not_subject": "Not subject", + "custom": "Custom" }, "calculation": { - "additive": "Add to amount", "subtractive": "Subtract from amount", "neutral": "No amount effect" + "additive": "Add to amount", + "subtractive": "Subtract from amount", + "neutral": "No amount effect" }, "scope": { - "domestic": "Domestic", "intra_eu": "Intra-EU", "export": "Export", "import": "Import", "international": "International" + "domestic": "Domestic", + "intra_eu": "Intra-EU", + "export": "Export", + "import": "Import", + "international": "International" }, "country": { - "ES": "Spain", "FR": "France", "DE": "Germany", "IT": "Italy", "PT": "Portugal", "US": "United States", "MX": "Mexico", "AR": "Argentina" + "ES": "Spain", + "FR": "France", + "DE": "Germany", + "IT": "Italy", + "PT": "Portugal", + "US": "United States", + "MX": "Mexico", + "AR": "Argentina" } }, - "actions": { "create": "New tax type", "save": "Save", "cancel": "Cancel", "edit": "Edit", "enable": "Enable", "disable": "Disable", "delete": "Delete", "more": "More actions", "retry": "Retry", "clearFilters": "Clear filters" }, + "actions": { + "create": "New tax type", + "save": "Save", + "cancel": "Cancel", + "edit": "Edit", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "more": "More actions", + "retry": "Retry", + "clearFilters": "Clear filters" + }, "status": { "all": "All", "active": "Active", "inactive": "Inactive", "system": "System" }, - "toolbar": { "search": "Search tax types", "status": "Status" }, + "toolbar": { "search": "Search by code, name or description...", "status": "Status" }, + "filters": { + "taxFamily": "Tax family", + "scope": "Scope", + "status": "Status", + "all": "All", + "allFemale": "All" + }, + "columns": { + "code": "Code", + "name": "Name", + "rate": "Rate", + "taxFamily": "Tax family", + "scope": "Scope", + "validity": "Validity", + "status": "Status", + "system": "System", + "actions": "Actions" + }, + "system": { "system": "System", "custom": "Custom" }, + "validity": { "from": "From", "to": "To" }, "create": { "title": "New tax type", "description": "Create a tax type for this company." }, "edit": { "title": "Edit tax type", "description": "Update the selected tax type." }, "delete": { "title": "Delete tax type", "description": "Delete {{code}} — {{name}}?" }, - "empty": { "default": "There are no tax types configured.", "filtered": "No results match the selected filters." }, + "empty": { + "default": "There are no tax types.", + "filtered": "No tax types match the current filters." + }, "error": { "title": "The settings could not be loaded" } } } diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json index c32a7ac6..f644b25d 100644 --- a/modules/catalogs/src/common/locales/es.json +++ b/modules/catalogs/src/common/locales/es.json @@ -397,36 +397,115 @@ "taxDefinitions": { "title": "Tipos de impuesto", "description": "Gestiona los tipos de impuesto disponibles para la empresa.", - "form": { "identity": "Identificación fiscal", "calculation": "Cálculo y jurisdicción", "validity": "Periodo de vigencia", "texts": "Textos del documento" }, + "form": { + "identity": "Identificación fiscal", + "calculation": "Cálculo y jurisdicción", + "validity": "Periodo de vigencia", + "texts": "Textos del documento" + }, "fields": { - "code": "Código", "name": "Nombre", "rate": "Tipo (%)", - "taxFamily": "Familia fiscal", "calculationBehavior": "Cálculo", - "taxScope": "Ámbito", "country": "País", "region": "Región", - "validFrom": "Válido desde", "validTo": "Válido hasta", - "description": "Descripción", "invoiceNote": "Nota en factura", + "code": "Código", + "name": "Nombre", + "rate": "Tipo (%)", + "taxFamily": "Familia fiscal", + "calculationBehavior": "Cálculo", + "taxScope": "Ámbito", + "country": "País", + "region": "Región", + "validFrom": "Válido desde", + "validTo": "Válido hasta", + "description": "Descripción", + "invoiceNote": "Nota en factura", "allowedSurchargeCodes": "Recargos permitidos" }, "options": { "family": { - "iva": "IVA", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Recargo de equivalencia", "withholding": "Retención", "vat": "IVA internacional", "gst": "Impuesto sobre bienes y servicios", "sales_tax": "Impuesto sobre ventas", "reverse_charge": "Inversión del sujeto pasivo", "exempt": "Exento", "not_subject": "No sujeto", "custom": "Personalizado" + "iva": "IVA", + "igic": "IGIC", + "ipsi": "IPSI", + "equivalence_surcharge": "Recargo de equivalencia", + "withholding": "Retención", + "vat": "IVA internacional", + "gst": "Impuesto sobre bienes y servicios", + "sales_tax": "Impuesto sobre ventas", + "reverse_charge": "Inversión del sujeto pasivo", + "exempt": "Exento", + "not_subject": "No sujeto", + "custom": "Personalizado" }, "calculation": { - "additive": "Añadir al importe", "subtractive": "Descontar del importe", "neutral": "Sin efecto en el importe" + "additive": "Añadir al importe", + "subtractive": "Descontar del importe", + "neutral": "Sin efecto en el importe" }, "scope": { - "domestic": "Nacional", "intra_eu": "Intracomunitario", "export": "Exportación", "import": "Importación", "international": "Internacional" + "domestic": "Nacional", + "intra_eu": "Intracomunitario", + "export": "Exportación", + "import": "Importación", + "international": "Internacional" }, "country": { - "ES": "España", "FR": "Francia", "DE": "Alemania", "IT": "Italia", "PT": "Portugal", "US": "Estados Unidos", "MX": "México", "AR": "Argentina" + "ES": "España", + "FR": "Francia", + "DE": "Alemania", + "IT": "Italia", + "PT": "Portugal", + "US": "Estados Unidos", + "MX": "México", + "AR": "Argentina" } }, - "actions": { "create": "Nuevo tipo", "save": "Guardar", "cancel": "Cancelar", "edit": "Editar", "enable": "Activar", "disable": "Desactivar", "delete": "Eliminar", "more": "Más acciones", "retry": "Reintentar", "clearFilters": "Limpiar filtros" }, + "actions": { + "create": "Nuevo tipo", + "save": "Guardar", + "cancel": "Cancelar", + "edit": "Editar", + "enable": "Activar", + "disable": "Desactivar", + "delete": "Eliminar", + "more": "Más acciones", + "retry": "Reintentar", + "clearFilters": "Limpiar filtros" + }, "status": { "all": "Todos", "active": "Activo", "inactive": "Inactivo", "system": "Sistema" }, - "toolbar": { "search": "Buscar tipos de impuesto", "status": "Estado" }, - "create": { "title": "Nuevo tipo de impuesto", "description": "Crea un tipo de impuesto para esta empresa." }, - "edit": { "title": "Editar tipo de impuesto", "description": "Actualiza el tipo de impuesto seleccionado." }, - "delete": { "title": "Eliminar tipo de impuesto", "description": "¿Eliminar {{code}} — {{name}}?" }, - "empty": { "default": "No hay tipos de impuesto configurados.", "filtered": "No hay resultados para los filtros seleccionados." }, + "toolbar": { "search": "Buscar por código, nombre o descripción...", "status": "Estado" }, + "filters": { + "taxFamily": "Familia fiscal", + "scope": "Ámbito", + "status": "Estado", + "all": "Todos", + "allFemale": "Todas" + }, + "columns": { + "code": "Código", + "name": "Nombre", + "rate": "Tipo", + "taxFamily": "Familia fiscal", + "scope": "Ámbito", + "validity": "Vigencia", + "status": "Estado", + "system": "Sistema", + "actions": "Acciones" + }, + "system": { "system": "Sistema", "custom": "Personalizado" }, + "validity": { "from": "Desde", "to": "Hasta" }, + "create": { + "title": "Nuevo tipo de impuesto", + "description": "Crea un tipo de impuesto para esta empresa." + }, + "edit": { + "title": "Editar tipo de impuesto", + "description": "Actualiza el tipo de impuesto seleccionado." + }, + "delete": { + "title": "Eliminar tipo de impuesto", + "description": "¿Eliminar {{code}} — {{name}}?" + }, + "empty": { + "default": "No hay tipos de impuesto.", + "filtered": "No se han encontrado tipos de impuesto con los filtros actuales." + }, "error": { "title": "No se pudo cargar la configuración" } } } diff --git a/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts index 09ea82b2..a1a9ca0f 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts +++ b/modules/catalogs/src/web/settings/tax-definitions/controllers/use-tax-definitions-settings-page-controller.ts @@ -1,8 +1,12 @@ -import { useMemo, useState } from "react"; +import type { DataTableSort } from "@repo/rdx-ui/components"; +import { useCallback, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { useTaxDefinitionListQuery } from "../../../tax-definitions/shared"; import { + TAX_DEFINITIONS_DEFAULT_SORT, + type TaxDefinitionsFamilyFilter, + type TaxDefinitionsScopeFilter, type TaxDefinitionsStatusFilter, buildTaxDefinitionsListCriteria, hasTaxDefinitionsListCriteriaApplied, @@ -13,12 +17,26 @@ import { useTaxDefinitionsSettingsController } from "./use-tax-definitions-setti export const useTaxDefinitionsSettingsPageController = () => { const [search, setSearch] = useState(""); const [status, setStatus] = useState("all"); + const [family, setFamily] = useState("all"); + const [scope, setScope] = useState("all"); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [sort, setSortState] = useState(TAX_DEFINITIONS_DEFAULT_SORT); const [debouncedSearch] = useDebounce(search, 300); const criteria = useMemo( - () => buildTaxDefinitionsListCriteria({ search: debouncedSearch, status }), - [debouncedSearch, status] + () => + buildTaxDefinitionsListCriteria({ + search: debouncedSearch, + status, + family, + scope, + pageIndex, + pageSize, + sort, + }), + [debouncedSearch, family, pageIndex, pageSize, scope, sort, status] ); const listQuery = useTaxDefinitionListQuery({ criteria }); @@ -30,8 +48,15 @@ export const useTaxDefinitionsSettingsPageController = () => { const hasAppliedCriteria = hasTaxDefinitionsListCriteriaApplied({ search: debouncedSearch, status, + family, + scope, }); + const resetPageAndSet = useCallback((setter: (value: T) => void, value: T) => { + setter(value); + setPageIndex(0); + }, []); + const retry = () => { listQuery.refetch(); }; @@ -45,12 +70,25 @@ export const useTaxDefinitionsSettingsPageController = () => { hasEmptyState: !isInitialLoading && totalItems === 0 && !hasAppliedCriteria, hasAppliedCriteria, search, - setSearch, + setSearch: (value: string) => resetPageAndSet(setSearch, value), status, - setStatus, + setStatus: (value: TaxDefinitionsStatusFilter) => resetPageAndSet(setStatus, value), + family, + setFamily: (value: TaxDefinitionsFamilyFilter) => resetPageAndSet(setFamily, value), + scope, + setScope: (value: TaxDefinitionsScopeFilter) => resetPageAndSet(setScope, value), + pageIndex, + setPageIndex, + pageSize, + setPageSize: (value: number) => resetPageAndSet(setPageSize, value), + sort, + setSort: (value: DataTableSort) => resetPageAndSet(setSortState, value), clearFilters: () => { setSearch(""); setStatus("all"); + setFamily("all"); + setScope("all"); + setPageIndex(0); }, criteria, pageError: listQuery.isError ? listQuery.error : null, diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts b/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts index 33a31ece..f2479b1b 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/index.ts @@ -1,4 +1,5 @@ export * from "./delete-tax-definition-dialog"; +export * from "./tax-definition-actions"; export * from "./tax-definition-card"; export * from "./tax-definition-form-dialog"; export * from "./tax-definitions-empty-state"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx index 5a7adc57..bc8e26b5 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/components/tax-definitions-settings-skeleton.tsx @@ -1,11 +1,16 @@ -import { Card, Skeleton } from "@repo/shadcn-ui/components"; +import { SkeletonDataTable } from "@repo/rdx-ui/components"; +import { Card, CardContent, CardHeader, Skeleton } from "@repo/shadcn-ui/components"; export const TaxDefinitionsSettingsSkeleton = () => ( -
- - {[1, 2, 3].map((id) => ( - - - - ))} -
+ + +
+ + +
+ +
+ + + +
); diff --git a/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx index 881d448a..9d34a317 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx +++ b/modules/catalogs/src/web/settings/tax-definitions/ui/pages/tax-definitions-settings-page.tsx @@ -1,4 +1,10 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; +import { + DataTable, + DataTableColumnHeader, + type DataTableProps, + RdxBadge, +} from "@repo/rdx-ui/components"; import { Button, Card, @@ -10,29 +16,264 @@ import { SelectTrigger, SelectValue, } from "@repo/shadcn-ui/components"; -import { PlusIcon } from "lucide-react"; +import { PlusIcon, RotateCcwIcon } from "lucide-react"; +import { useMemo } from "react"; import { useTranslation } from "../../../../i18n"; +import type { + TaxDefinitionFamily, + TaxDefinitionListRow, + TaxScope, +} from "../../../../tax-definitions/shared"; import { useTaxDefinitionsSettingsPageController } from "../../controllers"; -import type { TaxDefinitionsStatusFilter } from "../../utils"; +import type { + TaxDefinitionsFamilyFilter, + TaxDefinitionsScopeFilter, + TaxDefinitionsStatusFilter, +} from "../../utils"; import { DeleteTaxDefinitionDialog, - TaxDefinitionCard, + TaxDefinitionActions, TaxDefinitionFormDialog, TaxDefinitionsEmptyState, TaxDefinitionsSettingsSkeleton, } from "../components"; +const TAX_FAMILIES: TaxDefinitionFamily[] = [ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", +]; +const TAX_SCOPES: TaxScope[] = ["domestic", "intra_eu", "export", "import", "international"]; +type TaxDefinitionColumns = DataTableProps["columns"]; + +const formatRate = (rate: number, locale: string) => + new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format( + rate + ); + +const formatCivilDate = (value: string, locale: string) => { + const [year, month, day] = value.split("-").map(Number); + return new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(new Date(year, month - 1, day)); +}; + +const formatValidity = (row: TaxDefinitionListRow, locale: string, from: string, to: string) => { + if (row.validFrom && row.validTo) { + return `${formatCivilDate(row.validFrom, locale)} – ${formatCivilDate(row.validTo, locale)}`; + } + if (row.validFrom) { + return `${from} ${formatCivilDate(row.validFrom, locale)}`; + } + if (row.validTo) { + return `${to} ${formatCivilDate(row.validTo, locale)}`; + } + return "—"; +}; + +const getFamilyBadgeClassName = (family: TaxDefinitionFamily) => { + if (["iva", "igic", "ipsi", "vat"].includes(family)) { + return "bg-primary/10 text-primary"; + } + if (["equivalence_surcharge", "withholding"].includes(family)) { + return "bg-accent text-accent-foreground"; + } + return "bg-muted text-muted-foreground"; +}; + export const TaxDefinitionsSettingsPage = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const pageCtrl = useTaxDefinitionsSettingsPageController(); const featureCtrl = pageCtrl.controller; + const locale = i18n.language === "en" ? "en-US" : "es-ES"; const status = (value: TaxDefinitionsStatusFilter | null) => pageCtrl.setStatus(value ?? "all"); + const family = (value: TaxDefinitionsFamilyFilter | null) => pageCtrl.setFamily(value ?? "all"); + const scope = (value: TaxDefinitionsScopeFilter | null) => pageCtrl.setScope(value ?? "all"); + + const columns = useMemo( + () => [ + { + accessorKey: "code", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => {row.original.code}, + }, + { + accessorKey: "name", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.description ? ( + + {row.original.description} + + ) : null} +
+ ), + }, + { + id: "rate", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatRate(row.original.rate, locale)} % + + ), + }, + { + id: "taxFamily", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {t(`settings.taxDefinitions.options.family.${row.original.taxFamily}`)} + + ), + }, + { + id: "scope", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.jurisdictionCountryCode} + {row.original.jurisdictionRegionCode ? ( + + {row.original.jurisdictionRegionCode} + + ) : null} + + {t(`settings.taxDefinitions.options.scope.${row.original.taxScope}`)} + +
+ ), + }, + { + id: "validity", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatValidity( + row.original, + locale, + t("settings.taxDefinitions.validity.from"), + t("settings.taxDefinitions.validity.to") + )} + + ), + }, + { + id: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ●{" "} + {t(`settings.taxDefinitions.status.${row.original.isActive ? "active" : "inactive"}`)} + + ), + }, + { + id: "system", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {t(`settings.taxDefinitions.system.${row.original.isSystem ? "system" : "custom"}`)} + + ), + }, + { + id: "actions", + enableSorting: false, + meta: { + isActionsColumn: true, + headerClassName: "text-right", + cellClassName: "flex justify-end", + }, + header: t("settings.taxDefinitions.columns.actions"), + cell: ({ row }) => ( + + item.isActive ? featureCtrl.disable(item) : featureCtrl.enable(item) + } + /> + ), + }, + ], + [featureCtrl, locale, t] + ); if (pageCtrl.isInitialLoading) { return ; } - if (pageCtrl.pageError) { return ( { return ( <> - +

{t("settings.taxDefinitions.title")}

-

{t("settings.taxDefinitions.description")}

+

{t("settings.taxDefinitions.description")}

-
- -
- - -
- {pageCtrl.list.length ? ( - pageCtrl.list.map((item) => ( - - row.isActive ? featureCtrl.disable(row) : featureCtrl.enable(row) - } - /> - )) - ) : ( + + +
+ +
+
+ + {t("settings.taxDefinitions.filters.taxFamily")} + + +
+
+ + {t("settings.taxDefinitions.filters.scope")} + + +
+
+ + {t("settings.taxDefinitions.filters.status")} + + +
+ + + } + totalItems={pageCtrl.totalItems} + /> + {pageCtrl.totalItems === 0 ? ( - )} + ) : null}
{ - assert.deepEqual(buildTaxDefinitionsListCriteria({ search: " iva ", status: "active" }), { - q: "iva", - filters: [{ field: "isActive", operator: "EQUALS", value: "true" }], - pageNumber: 0, - pageSize: 9999, - orderBy: "code", - order: "asc", - }); +test("construye búsqueda, filtros y orden server-side", () => { + assert.deepEqual( + buildTaxDefinitionsListCriteria({ + search: " iva ", + status: "active", + family: "iva", + scope: "domestic", + pageIndex: 2, + pageSize: 25, + sort: { field: "name", direction: "desc" }, + }), + { + q: "iva", + filters: [ + { field: "isActive", operator: "EQUALS", value: "true" }, + { field: "taxFamily", operator: "EQUALS", value: "iva" }, + { field: "taxScope", operator: "EQUALS", value: "domestic" }, + ], + pageNumber: 2, + pageSize: 25, + orderBy: "name", + order: "desc", + } + ); }); diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts index e69117c6..da3f37f1 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/build-tax-definitions-list-criteria.ts @@ -1,23 +1,61 @@ import type { CriteriaDTO } from "@erp/core"; +import type { DataTableSort } from "@repo/rdx-ui/components"; + +import type { TaxDefinitionFamily, TaxScope } from "../../../tax-definitions/shared"; export type TaxDefinitionsStatusFilter = "all" | "active" | "inactive"; +export type TaxDefinitionsFamilyFilter = "all" | TaxDefinitionFamily; +export type TaxDefinitionsScopeFilter = "all" | TaxScope; + +const DEFAULT_SORT = { field: "code", direction: "asc" } satisfies DataTableSort; + +const SORT_FIELDS = { + code: "code", + name: "name", +} as const; + +export const TAX_DEFINITIONS_DEFAULT_SORT = DEFAULT_SORT; export const buildTaxDefinitionsListCriteria = (params: { search: string; status: TaxDefinitionsStatusFilter; + family: TaxDefinitionsFamilyFilter; + scope: TaxDefinitionsScopeFilter; + pageIndex: number; + pageSize: number; + sort: DataTableSort; }): CriteriaDTO => ({ q: params.search.trim() || undefined, - filters: - params.status === "all" - ? undefined - : [{ field: "isActive", operator: "EQUALS", value: String(params.status === "active") }], - pageNumber: 0, - pageSize: 9999, - orderBy: "code", - order: "asc", + filters: [ + ...(params.status === "all" + ? [] + : [ + { + field: "isActive", + operator: "EQUALS" as const, + value: String(params.status === "active"), + }, + ]), + ...(params.family === "all" + ? [] + : [{ field: "taxFamily", operator: "EQUALS" as const, value: params.family }]), + ...(params.scope === "all" + ? [] + : [{ field: "taxScope", operator: "EQUALS" as const, value: params.scope }]), + ], + pageNumber: params.pageIndex, + pageSize: params.pageSize, + orderBy: SORT_FIELDS[params.sort.field as keyof typeof SORT_FIELDS] ?? DEFAULT_SORT.field, + order: params.sort.direction, }); export const hasTaxDefinitionsListCriteriaApplied = (params: { search: string; status: TaxDefinitionsStatusFilter; -}) => params.search.trim().length > 0 || params.status !== "all"; + family: TaxDefinitionsFamilyFilter; + scope: TaxDefinitionsScopeFilter; +}) => + params.search.trim().length > 0 || + params.status !== "all" || + params.family !== "all" || + params.scope !== "all"; diff --git a/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts index a84bd3c4..4950600d 100644 --- a/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts +++ b/modules/catalogs/src/web/settings/tax-definitions/utils/tax-definition-actions.test.ts @@ -15,6 +15,14 @@ const system: TaxDefinitionListRow = { companyId: "company", code: "iva_21", name: "IVA", + description: null, + rate: 21, + taxFamily: "iva", + jurisdictionCountryCode: "ES", + jurisdictionRegionCode: null, + taxScope: "domestic", + validFrom: null, + validTo: null, isActive: true, isSystem: true, }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.test.ts b/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.test.ts index 3458bfb2..2cd2701c 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.test.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.test.ts @@ -1,23 +1,101 @@ import assert from "node:assert/strict"; import test from "node:test"; + import type { ListTaxDefinitionsResponseDTO } from "../../../../common"; + import { ListTaxDefinitionsAdapter } from "./list-tax-definitions.adapter"; test("adapta paginación y filas ligeras preservando flags", () => { const dto: ListTaxDefinitionsResponseDTO = { - page: 1, per_page: 25, total_pages: 3, total_items: 52, - metadata: { entity: "tax_definitions", criteria: { q: "iva", filters: [], orderBy: "code", orderType: "ASC", pageSize: 25, pageNumber: 1 } }, + page: 1, + per_page: 25, + total_pages: 3, + total_items: 52, + metadata: { + entity: "tax_definitions", + criteria: { + q: "iva", + filters: [], + orderBy: "code", + orderType: "ASC", + pageSize: 25, + pageNumber: 1, + }, + }, items: [ - { id: "11111111-1111-4111-8111-111111111111", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_21", name: "IVA 21", is_active: true, is_system: false }, - { id: "33333333-3333-4333-8333-333333333333", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_10", name: "IVA 10", is_active: false, is_system: true }, + { + id: "11111111-1111-4111-8111-111111111111", + company_id: "22222222-2222-4222-8222-222222222222", + code: "iva_21", + name: "IVA 21", + description: "General", + rate: { value: "2100", scale: "2" }, + tax_family: "iva", + jurisdiction_country_code: "ES", + jurisdiction_region_code: null, + tax_scope: "domestic", + valid_from: null, + valid_to: null, + is_active: true, + is_system: false, + }, + { + id: "33333333-3333-4333-8333-333333333333", + company_id: "22222222-2222-4222-8222-222222222222", + code: "iva_10", + name: "IVA 10", + description: null, + rate: { value: "1000", scale: "2" }, + tax_family: "iva", + jurisdiction_country_code: "ES", + jurisdiction_region_code: null, + tax_scope: "domestic", + valid_from: "2024-01-01", + valid_to: "2025-12-31", + is_active: false, + is_system: true, + }, ], }; assert.deepEqual(ListTaxDefinitionsAdapter.fromDto(dto), { - page: 1, perPage: 25, totalPages: 3, totalItems: 52, + page: 1, + perPage: 25, + totalPages: 3, + totalItems: 52, items: [ - { id: dto.items[0].id, companyId: dto.items[0].company_id, code: "iva_21", name: "IVA 21", isActive: true, isSystem: false }, - { id: dto.items[1].id, companyId: dto.items[1].company_id, code: "iva_10", name: "IVA 10", isActive: false, isSystem: true }, + { + id: dto.items[0].id, + companyId: dto.items[0].company_id, + code: "iva_21", + name: "IVA 21", + description: "General", + rate: 21, + taxFamily: "iva", + jurisdictionCountryCode: "ES", + jurisdictionRegionCode: null, + taxScope: "domestic", + validFrom: null, + validTo: null, + isActive: true, + isSystem: false, + }, + { + id: dto.items[1].id, + companyId: dto.items[1].company_id, + code: "iva_10", + name: "IVA 10", + description: null, + rate: 10, + taxFamily: "iva", + jurisdictionCountryCode: "ES", + jurisdictionRegionCode: null, + taxScope: "domestic", + validFrom: "2024-01-01", + validTo: "2025-12-31", + isActive: false, + isSystem: true, + }, ], }); }); diff --git a/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.ts b/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.ts index 6ea00e39..7a8ac937 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/adapters/list-tax-definitions.adapter.ts @@ -1,3 +1,5 @@ +import { Percentage } from "@repo/rdx-ddd"; + import type { ListTaxDefinitionsResponseDTO } from "../../../../common"; import type { ListTaxDefinitionsResult } from "../api"; import type { TaxDefinitionList, TaxDefinitionListRow } from "../entities"; @@ -6,12 +8,38 @@ type TaxDefinitionListItemDTO = ListTaxDefinitionsResponseDTO["items"][number]; const TaxDefinitionListRowAdapter = { fromDto(dto: TaxDefinitionListItemDTO): TaxDefinitionListRow { - return { id: dto.id, companyId: dto.company_id, code: dto.code, name: dto.name, isSystem: dto.is_system, isActive: dto.is_active }; + const rate = Percentage.fromObjectString(dto.rate); + if (rate.isFailure) { + throw rate.error; + } + + return { + id: dto.id, + companyId: dto.company_id, + code: dto.code, + name: dto.name, + description: dto.description, + rate: rate.data.toNumber(), + taxFamily: dto.tax_family, + jurisdictionCountryCode: dto.jurisdiction_country_code, + jurisdictionRegionCode: dto.jurisdiction_region_code, + taxScope: dto.tax_scope, + validFrom: dto.valid_from, + validTo: dto.valid_to, + isSystem: dto.is_system, + isActive: dto.is_active, + }; }, }; export const ListTaxDefinitionsAdapter = { fromDto(dto: ListTaxDefinitionsResult): TaxDefinitionList { - return { page: dto.page, perPage: dto.per_page, totalPages: dto.total_pages, totalItems: dto.total_items, items: dto.items.map(TaxDefinitionListRowAdapter.fromDto) }; + return { + page: dto.page, + perPage: dto.per_page, + totalPages: dto.total_pages, + totalItems: dto.total_items, + items: dto.items.map(TaxDefinitionListRowAdapter.fromDto), + }; }, }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/entities/tax-definition-list-row.entity.ts b/modules/catalogs/src/web/tax-definitions/shared/entities/tax-definition-list-row.entity.ts index 46930a56..3b4338e8 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/entities/tax-definition-list-row.entity.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/entities/tax-definition-list-row.entity.ts @@ -1,8 +1,18 @@ +import type { TaxDefinitionFamily, TaxScope } from "./tax-definition.entity"; + export interface TaxDefinitionListRow { id: string; companyId: string; code: string; name: string; + description: string | null; + rate: number; + taxFamily: TaxDefinitionFamily; + jurisdictionCountryCode: string; + jurisdictionRegionCode: string | null; + taxScope: TaxScope; + validFrom: string | null; + validTo: string | null; isSystem: boolean; isActive: boolean; } diff --git a/modules/core/src/api/infrastructure/express/express-guards.ts b/modules/core/src/api/infrastructure/express/express-guards.ts index 54a5896f..3edbe94b 100644 --- a/modules/core/src/api/infrastructure/express/express-guards.ts +++ b/modules/core/src/api/infrastructure/express/express-guards.ts @@ -66,6 +66,19 @@ export function forbidQueryFieldGuard(field = "companyId"): RequestHandler { }; } +/** + * Variante para controllers que ejecutan guards mediante ExpressController. + * Evita invocar el siguiente middleware mientras el controller aún está validando la petición. + */ +export function forbidQueryFieldControllerGuard(field = "companyId"): GuardFn { + return ({ req }) => { + if (Object.hasOwn(req.query ?? {}, field)) { + return guardFail(new ValidationApiError(`Query param "${field}" is not allowed`)); + } + return guardOk(); + }; +} + /** Inyecta el tenant del usuario en el body (create/update). Ignora cualquier valor que venga del cliente. diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts index 15266694..5ead64e3 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts @@ -10,10 +10,52 @@ import { CompleteRecurringProformaController, DeleteRecurringProformaController, DuplicateRecurringProformaController, + ListRecurringProformasController, PauseRecurringProformaController, ResumeRecurringProformaController, } from "./recurring-proformas-read.controllers"; +test("list executes controller guards and delegates a valid query", async () => { + const companyId = UniqueID.generateNewID(); + const listCalls: Array<{ companyId: typeof companyId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new ListRecurringProformasController( + async (input: { companyId: typeof companyId }) => { + listCalls.push(input); + return Result.ok({ page: 1, perPage: 20, totalItems: 0, totalPages: 0, items: [] }); + } + ); + const req = { + user: { companyId }, + query: {}, + originalUrl: "/recurring-proformas", + method: "GET", + protocol: "http", + get: () => undefined, + }; + const res = { + status: (value: number) => { + statusCode = value; + return res; + }, + json: (value: unknown) => { + body = value; + return res; + }, + sendStatus: () => res, + type: () => res, + setHeader: () => undefined, + }; + + await controller.execute(req as never, res as never, () => undefined); + + assert.equal(listCalls.length, 1); + assert.equal(listCalls[0]?.companyId.equals(companyId), true); + assert.equal(statusCode, 200); + assert.deepEqual(body, { page: 1, per_page: 20, total_items: 0, total_pages: 0, items: [] }); +}); + test("archive success delegates once and returns HTTP success", async () => { const companyId = UniqueID.generateNewID(); const recurringProformaId = UniqueID.generateNewID(); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts index 0f309b38..c53b2299 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts @@ -1,6 +1,6 @@ import { ExpressController, - forbidQueryFieldGuard, + forbidQueryFieldControllerGuard, requireAuthenticatedGuard, requireCompanyContextGuard, } from "@erp/core/api"; @@ -58,7 +58,7 @@ abstract class RecurringProformaController extends ExpressController { this.registerGuards( requireAuthenticatedGuard(), requireCompanyContextGuard(), - forbidQueryFieldGuard("companyId") + forbidQueryFieldControllerGuard("companyId") ); } protected company() {