diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice-v2.repository.ts index 188fbede..e1c6dc67 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice-v2.repository.ts @@ -185,7 +185,15 @@ export class SequelizeIssuedInvoiceRepositoryV2 recipient_name: { type: "root", column: "customer_name" }, verifactu_status: { type: "association", association: "verifactu", column: "estado" }, }, - sortableFields: ["recipient_name", "invoice_number", "invoice_date", "id", "created_at", "serie"], + sortableFields: [ + "recipient_name", + "invoice_number", + "invoice_reference", + "invoice_date", + "id", + "created_at", + "serie", + ], fullTextTableAlias: "IssuedInvoiceModel", enableFullText: true, database: this.database, diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.test.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.test.ts new file mode 100644 index 00000000..7aae5dd6 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_ISSUED_INVOICE_API_SORT_FIELD, + DEFAULT_ISSUED_INVOICE_LIST_SORT, + ISSUED_INVOICE_LIST_SORT_FIELDS, + isIssuedInvoiceListSortField, + isSortDirection, +} from "./issued-invoice-list-sorting"; + +test("mantiene el mismo contrato semántico de ordenación que usa el grid", () => { + assert.deepEqual(ISSUED_INVOICE_LIST_SORT_FIELDS, { + invoiceReference: "invoice_reference", + recipientName: "recipient_name", + invoiceDate: "invoice_date", + }); + assert.deepEqual(DEFAULT_ISSUED_INVOICE_LIST_SORT, { + field: "invoiceDate", + direction: "desc", + }); + assert.equal(DEFAULT_ISSUED_INVOICE_API_SORT_FIELD, "invoice_date"); +}); + +test("solo acepta campos y direcciones de orden válidos", () => { + assert.equal(isIssuedInvoiceListSortField("invoiceReference"), true); + assert.equal(isIssuedInvoiceListSortField("invoiceDate"), true); + assert.equal(isIssuedInvoiceListSortField("invoice_reference"), false); + assert.equal(isIssuedInvoiceListSortField(null), false); + + assert.equal(isSortDirection("asc"), true); + assert.equal(isSortDirection("desc"), true); + assert.equal(isSortDirection("DESC"), false); + assert.equal(isSortDirection(null), false); +}); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.ts new file mode 100644 index 00000000..46870657 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/issued-invoice-list-sorting.ts @@ -0,0 +1,26 @@ +import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components"; + +export const ISSUED_INVOICE_LIST_SORT_FIELDS = { + invoiceReference: "invoice_reference", + recipientName: "recipient_name", + invoiceDate: "invoice_date", +} as const; + +export type IssuedInvoiceListSortField = keyof typeof ISSUED_INVOICE_LIST_SORT_FIELDS; + +export const DEFAULT_ISSUED_INVOICE_LIST_SORT = { + field: "invoiceDate", + direction: "desc", +} satisfies DataTableSort; + +export const DEFAULT_ISSUED_INVOICE_API_SORT_FIELD = ISSUED_INVOICE_LIST_SORT_FIELDS.invoiceDate; + +export const isIssuedInvoiceListSortField = ( + value: string | null +): value is IssuedInvoiceListSortField => { + return value !== null && value in ISSUED_INVOICE_LIST_SORT_FIELDS; +}; + +export const isSortDirection = (value: string | null): value is DataTableSortDirection => { + return value === "asc" || value === "desc"; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts index bc057005..4ed63f9f 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts @@ -1,6 +1,13 @@ import { useDataSource, useDebounce } from "@erp/core/hooks"; +import { INITIAL_PAGE_INDEX } from "@repo/rdx-criteria"; +import { + type DataTableSort, + useDataTablePreferences, +} from "@repo/rdx-ui/components"; +import { NumberHelper } from "@repo/rdx-utils"; import { useQueries } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import type { IssuedInvoiceList, @@ -13,12 +20,20 @@ import { getListIssuedInvoicesByCriteria, } from "../../shared"; import { useIssuedInvoiceListQuery } from "../../shared/"; +import { + DEFAULT_ISSUED_INVOICE_API_SORT_FIELD, + DEFAULT_ISSUED_INVOICE_LIST_SORT, + type IssuedInvoiceListSortField, + ISSUED_INVOICE_LIST_SORT_FIELDS, + isIssuedInvoiceListSortField, + isSortDirection, +} from "./issued-invoice-list-sorting"; type VerifactuRecordListStatusFilter = "all" | VerifactuRecordStatus; const EMPTY_ISSUED_INVOICES_LIST: IssuedInvoiceList = { items: [], - page: 0, + page: INITIAL_PAGE_INDEX, perPage: 5, totalPages: 0, totalItems: 0, @@ -42,27 +57,74 @@ const STATUS_COUNT_OPTIONS: ReadonlyArray<{ export const useIssuedInvoiceListController = () => { const dataSource = useDataSource(); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [search, setSearch] = useState(""); + const [searchParams, setSearchParams] = useSearchParams(); + const [search, setSearch] = useState(searchParams.get("q") ?? ""); const [verifactuStatusFilter, setVerifactuStatusFilter] = - useState("all"); + useState( + (searchParams.get("status") as VerifactuRecordListStatusFilter | null) ?? "all" + ); + + const tablePreferences = useDataTablePreferences({ + storageKey: "issued-invoices:list:grid", + defaultPageSize: EMPTY_ISSUED_INVOICES_LIST.perPage, + defaultSort: DEFAULT_ISSUED_INVOICE_LIST_SORT, + }); + + const { pageSize: preferencesPageSize, setPageSize: setPageSizePreference } = tablePreferences; + const { sort: preferencesSorting, setSort: setSortPreference } = tablePreferences; + + const urlPage = NumberHelper.parsePositiveInteger( + searchParams.get("page"), + INITIAL_PAGE_INDEX + 1 + ); + const pageIndex = urlPage - 1; + + const urlPageSize = searchParams.get("pageSize"); + const pageSize = urlPageSize + ? NumberHelper.parsePositiveInteger(urlPageSize, preferencesPageSize) + : preferencesPageSize; const debouncedSearch = useDebounce(search, 300); + useEffect(() => { + setSearch(searchParams.get("q") ?? ""); + setVerifactuStatusFilter( + (searchParams.get("status") as VerifactuRecordListStatusFilter | null) ?? "all" + ); + }, [searchParams]); + + const urlSortFieldValue = searchParams.get("sortField"); + const urlSortDirectionValue = searchParams.get("sortDirection"); + + const urlSort = + isIssuedInvoiceListSortField(urlSortFieldValue) && isSortDirection(urlSortDirectionValue) + ? { + field: urlSortFieldValue, + direction: urlSortDirectionValue, + } + : undefined; + + const sort = urlSort ?? preferencesSorting ?? DEFAULT_ISSUED_INVOICE_LIST_SORT; + + const orderBy = + ISSUED_INVOICE_LIST_SORT_FIELDS[sort.field as IssuedInvoiceListSortField] ?? + DEFAULT_ISSUED_INVOICE_API_SORT_FIELD; + + const order = sort.direction; + const criteria = useMemo>( () => ({ q: debouncedSearch || "", pageNumber: pageIndex, pageSize, - order: "desc", - orderBy: "invoice_date", + order, + orderBy, filters: verifactuStatusFilter === "all" ? [] : [{ field: "verifactu_status", operator: "EQUALS", value: verifactuStatusFilter }], }), - [debouncedSearch, pageIndex, pageSize, verifactuStatusFilter] + [debouncedSearch, order, orderBy, pageIndex, pageSize, verifactuStatusFilter] ); const query = useIssuedInvoiceListQuery({ criteria }); @@ -104,42 +166,86 @@ export const useIssuedInvoiceListController = () => { return counts; }, [query.data?.totalItems, statusCountQueries]); - const setStatusFilterValue = (value: string) => { + const setStatusFilterValue = useCallback((value: string) => { const nextValue = (value || "all") as VerifactuRecordListStatusFilter; + if (verifactuStatusFilter === nextValue) return; - setVerifactuStatusFilter((prev) => { - if (prev === nextValue) return prev; + setVerifactuStatusFilter(nextValue); + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); - // Sólo si la búsqueda realmente cambia, - // reseteamos la página a 0 para evitar inconsistencias - setPageIndex(0); - return nextValue; + if (nextValue === "all") { + params.delete("status"); + } else { + params.set("status", nextValue); + } + + return params; }); - }; + }, [setSearchParams, verifactuStatusFilter]); - const setSearchValue = (value: string) => { + const setSearchValue = useCallback((value: string) => { const nextValue = value.trim().replace(/\s+/g, " "); + if (search === nextValue) return; - setSearch((prev) => { - if (prev === nextValue) return prev; + setSearch(nextValue); + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); - // Sólo si la búsqueda realmente cambia, - // reseteamos la página a 0 para evitar inconsistencias - setPageIndex(0); - return nextValue; + if (nextValue) { + params.set("q", nextValue); + } else { + params.delete("q"); + } + + return params; }); - }; + }, [search, setSearchParams]); - const setPageSizeValue = (value: number) => { - setPageSize((prev) => { - if (prev === value) return prev; + const setPageIndexValue = useCallback((newPageIndex: number) => { + const nextPage = newPageIndex + 1; - // Sólo si el tamaño de página realmente cambia, - // reseteamos la página a 0 para evitar inconsistencias - setPageIndex(0); - return value; + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(nextPage)); + return params; }); - }; + }, [setSearchParams]); + + const setPageSizeValue = useCallback((value: number) => { + if (pageSize === value) return; + + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + params.set("pageSize", String(value)); + return params; + }); + + setPageSizePreference(value); + }, [pageSize, setPageSizePreference, setSearchParams]); + + const setSortValue = useCallback((nextSort: DataTableSort) => { + if (!isIssuedInvoiceListSortField(nextSort.field)) { + return; + } + + if (sort.field === nextSort.field && sort.direction === nextSort.direction) { + return; + } + + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("sortField", nextSort.field); + params.set("sortDirection", nextSort.direction); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + return params; + }); + + setSortPreference(nextSort); + }, [setSearchParams, setSortPreference, sort.direction, sort.field]); return { queryData: query.data, @@ -155,14 +261,18 @@ export const useIssuedInvoiceListController = () => { error: query.error, refetch: query.refetch, + tablePreferences, + pageIndex, pageSize, - setPageIndex, + setPageIndex: setPageIndexValue, setPageSize: setPageSizeValue, search, setSearchValue, + sort, + setSort: setSortValue, statusFilter: verifactuStatusFilter, setStatusFilter: setStatusFilterValue, }; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx index aa9a8756..3b125dd2 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx @@ -1,4 +1,8 @@ -import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; +import { + DataTable, + type DataTableSort, + SkeletonDataTable, +} from "@repo/rdx-ui/components"; import type { ColumnDef } from "@tanstack/react-table"; import type * as React from "react"; @@ -19,6 +23,9 @@ interface IssuedInvoicesGridProps { onPageChange: (pageIndex: number) => void; onPageSizeChange: (size: number) => void; + sort: DataTableSort; + onSortChange?: (nextSort: DataTableSort) => void; + onRowClick?: (proformaId: string) => void; } @@ -33,6 +40,8 @@ export const IssuedInvoicesGrid = ({ pageSize, onPageChange, onPageSizeChange, + sort, + onSortChange, onRowClick, }: IssuedInvoicesGridProps) => { const { t } = useTranslation(); @@ -109,11 +118,14 @@ export const IssuedInvoicesGrid = ({ isFetching={fetching} isSearching={searching} manualPagination + manualSorting onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} + onSortChange={onSortChange} onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined} pageIndex={pageIndex} pageSize={pageSize} + sort={sort} topContent={topContent} totalItems={totalItems} /> diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.test.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.test.tsx new file mode 100644 index 00000000..8377af35 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.test.tsx @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { useIssuedInvoicesGridColumns } from "./use-issued-invoices-grid-columns"; + +const getColumns = () => { + let columns: ReturnType = []; + + const Harness = () => { + columns = useIssuedInvoicesGridColumns(); + return null; + }; + + renderToStaticMarkup(); + + return columns; +}; + +test("define un id estable y sortable para invoiceReference", () => { + const columns = getColumns(); + const invoiceReferenceColumn = columns.find((column) => column.id === "invoiceReference"); + const invoiceDateColumn = columns.find((column) => column.id === "invoiceDate"); + + assert.ok(invoiceReferenceColumn); + assert.equal(invoiceReferenceColumn.enableSorting, true); + assert.equal(invoiceReferenceColumn.accessorKey, "invoiceReference"); + + assert.ok(invoiceDateColumn); + assert.equal(invoiceDateColumn.enableSorting, true); + assert.equal(invoiceDateColumn.accessorKey, "invoiceDate"); +}); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx index 10d26a43..8728b745 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx @@ -33,25 +33,20 @@ export function useIssuedInvoicesGridColumns( () => [ // Nº { + id: "invoiceReference", header: ({ column }) => ( ), accessorKey: "invoiceReference", - /*cell: ({ row }) => ( -
- {row.original.series} - {row.original.invoiceNumber} -
- ),*/ + cell: ({ row }) => ( +
{row.original.invoiceReference}
+ ), enableHiding: false, enableSorting: true, - /*maxSize: 96, - size: 96, - minSize: 96,*/ meta: { title: t("pages.issued_invoices.list.grid_columns.invoice_reference"), cellClassName: "font-mono text-sm font-medium", @@ -192,7 +187,8 @@ export function useIssuedInvoicesGridColumns( // Fecha factura { - accessorKey: "invoice_date", + id: "invoiceDate", + accessorKey: "invoiceDate", header: ({ column }) => ( ), - enableSorting: false, + enableSorting: true, size: 140, minSize: 120, meta: { diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx index bff54e5f..5c5bbd9d 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx @@ -143,10 +143,12 @@ export const ListIssuedInvoicesPage = () => { loading={listCtrl.isLoading} onPageChange={listCtrl.setPageIndex} onPageSizeChange={listCtrl.setPageSize} + onSortChange={listCtrl.setSort} onRowClick={handleOpenDetail} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} searching={isSearchPending} + sort={listCtrl.sort} topContent={
diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.test.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.test.ts new file mode 100644 index 00000000..3e715c26 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MoneyDTOHelper } from "@erp/core"; + +import type { ListIssuedInvoicesResponseDTO } from "../../../../common"; +import { ListIssuedInvoicesAdapter } from "./list-issued-invoice.adapter"; + +const buildDto = (): ListIssuedInvoicesResponseDTO => ({ + page: 0, + per_page: 5, + total_pages: 1, + total_items: 1, + items: [ + { + id: "invoice-1", + company_id: "company-1", + document_series_id: "series-1", + invoice_series_code: "A", + invoice_number: "12", + invoice_reference: "A-12", + status: "issued", + series: "A", + invoice_date: "2026-08-10", + operation_date: "2026-08-10", + language_code: "es-ES", + currency_code: "EUR", + reference: "REF-12", + description: "Factura", + customer_id: "customer-1", + recipient: { + id: "recipient-1", + name: "Acme Labs", + tin: "B12345678", + street: "Calle Mayor 1", + street2: null, + city: "Madrid", + province: "Madrid", + postal_code: "28001", + country: "ES", + }, + subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + total_discount_amount: MoneyDTOHelper.fromNumber(5, "EUR"), + taxable_amount: MoneyDTOHelper.fromNumber(95, "EUR"), + taxes_amount: MoneyDTOHelper.fromNumber(19.95, "EUR"), + total_amount: MoneyDTOHelper.fromNumber(114.95, "EUR"), + verifactu: { + status: "Correcto", + url: "https://example.com/verifactu/A-12", + qr_code: "qr-code", + }, + linked_proforma_id: null, + }, + ], +}); + +test("expone invoiceReference en las filas del listado para el grid y la ordenación", () => { + const result = ListIssuedInvoicesAdapter.fromDto(buildDto()); + + assert.equal(result.items[0]?.invoiceReference, "A-12"); + assert.equal(result.items[0]?.invoiceNumber, "12"); + assert.equal(result.items[0]?.recipient.name, "Acme Labs"); + assert.equal(result.items[0]?.totalAmountFmt.length > 0, true); +}); diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.ts index 0b4cc473..98dcb727 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/list-issued-invoice.adapter.ts @@ -27,6 +27,7 @@ const IssuedInvoiceListRowAdapter = { id: dto.id, companyId: dto.company_id, + invoiceReference: dto.invoice_reference, invoiceNumber: dto.invoice_number, status: dto.status as IssuedInvoiceStatus, series: dto.series, diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-list-row.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-list-row.entity.ts index 4b902871..ffb8cf29 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-list-row.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-list-row.entity.ts @@ -13,6 +13,7 @@ export interface IssuedInvoiceListRow { id: string; companyId: string; + invoiceReference: string; invoiceNumber: string; status: IssuedInvoiceStatus; series: string;