feat: add sorting functionality and new fields for issued invoices

This commit is contained in:
David Arranz 2026-08-19 12:15:29 +02:00
parent 9c1f98df0c
commit bd630b29dd
11 changed files with 335 additions and 48 deletions

View File

@ -185,7 +185,15 @@ export class SequelizeIssuedInvoiceRepositoryV2
recipient_name: { type: "root", column: "customer_name" }, recipient_name: { type: "root", column: "customer_name" },
verifactu_status: { type: "association", association: "verifactu", column: "estado" }, 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", fullTextTableAlias: "IssuedInvoiceModel",
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,

View File

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

View File

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

View File

@ -1,6 +1,13 @@
import { useDataSource, useDebounce } from "@erp/core/hooks"; 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 { 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 { import type {
IssuedInvoiceList, IssuedInvoiceList,
@ -13,12 +20,20 @@ import {
getListIssuedInvoicesByCriteria, getListIssuedInvoicesByCriteria,
} from "../../shared"; } from "../../shared";
import { useIssuedInvoiceListQuery } 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; type VerifactuRecordListStatusFilter = "all" | VerifactuRecordStatus;
const EMPTY_ISSUED_INVOICES_LIST: IssuedInvoiceList = { const EMPTY_ISSUED_INVOICES_LIST: IssuedInvoiceList = {
items: [], items: [],
page: 0, page: INITIAL_PAGE_INDEX,
perPage: 5, perPage: 5,
totalPages: 0, totalPages: 0,
totalItems: 0, totalItems: 0,
@ -42,27 +57,74 @@ const STATUS_COUNT_OPTIONS: ReadonlyArray<{
export const useIssuedInvoiceListController = () => { export const useIssuedInvoiceListController = () => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const [pageIndex, setPageIndex] = useState(0); const [searchParams, setSearchParams] = useSearchParams();
const [pageSize, setPageSize] = useState(5); const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [search, setSearch] = useState("");
const [verifactuStatusFilter, setVerifactuStatusFilter] = const [verifactuStatusFilter, setVerifactuStatusFilter] =
useState<VerifactuRecordListStatusFilter>("all"); useState<VerifactuRecordListStatusFilter>(
(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); 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<NonNullable<ListIssuedInvoicesByCriteriaParams["criteria"]>>( const criteria = useMemo<NonNullable<ListIssuedInvoicesByCriteriaParams["criteria"]>>(
() => ({ () => ({
q: debouncedSearch || "", q: debouncedSearch || "",
pageNumber: pageIndex, pageNumber: pageIndex,
pageSize, pageSize,
order: "desc", order,
orderBy: "invoice_date", orderBy,
filters: filters:
verifactuStatusFilter === "all" verifactuStatusFilter === "all"
? [] ? []
: [{ field: "verifactu_status", operator: "EQUALS", value: verifactuStatusFilter }], : [{ field: "verifactu_status", operator: "EQUALS", value: verifactuStatusFilter }],
}), }),
[debouncedSearch, pageIndex, pageSize, verifactuStatusFilter] [debouncedSearch, order, orderBy, pageIndex, pageSize, verifactuStatusFilter]
); );
const query = useIssuedInvoiceListQuery({ criteria }); const query = useIssuedInvoiceListQuery({ criteria });
@ -104,42 +166,86 @@ export const useIssuedInvoiceListController = () => {
return counts; return counts;
}, [query.data?.totalItems, statusCountQueries]); }, [query.data?.totalItems, statusCountQueries]);
const setStatusFilterValue = (value: string) => { const setStatusFilterValue = useCallback((value: string) => {
const nextValue = (value || "all") as VerifactuRecordListStatusFilter; const nextValue = (value || "all") as VerifactuRecordListStatusFilter;
if (verifactuStatusFilter === nextValue) return;
setVerifactuStatusFilter((prev) => { setVerifactuStatusFilter(nextValue);
if (prev === nextValue) return prev; setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set("page", String(INITIAL_PAGE_INDEX + 1));
// Sólo si la búsqueda realmente cambia, if (nextValue === "all") {
// reseteamos la página a 0 para evitar inconsistencias params.delete("status");
setPageIndex(0); } else {
return nextValue; params.set("status", nextValue);
}
return params;
}); });
}; }, [setSearchParams, verifactuStatusFilter]);
const setSearchValue = (value: string) => { const setSearchValue = useCallback((value: string) => {
const nextValue = value.trim().replace(/\s+/g, " "); const nextValue = value.trim().replace(/\s+/g, " ");
if (search === nextValue) return;
setSearch((prev) => { setSearch(nextValue);
if (prev === nextValue) return prev; setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set("page", String(INITIAL_PAGE_INDEX + 1));
// Sólo si la búsqueda realmente cambia, if (nextValue) {
// reseteamos la página a 0 para evitar inconsistencias params.set("q", nextValue);
setPageIndex(0); } else {
return nextValue; params.delete("q");
}
return params;
}); });
}; }, [search, setSearchParams]);
const setPageSizeValue = (value: number) => { const setPageIndexValue = useCallback((newPageIndex: number) => {
setPageSize((prev) => { const nextPage = newPageIndex + 1;
if (prev === value) return prev;
// Sólo si el tamaño de página realmente cambia, setSearchParams((prev) => {
// reseteamos la página a 0 para evitar inconsistencias const params = new URLSearchParams(prev);
setPageIndex(0); params.set("page", String(nextPage));
return value; 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 { return {
queryData: query.data, queryData: query.data,
@ -155,14 +261,18 @@ export const useIssuedInvoiceListController = () => {
error: query.error, error: query.error,
refetch: query.refetch, refetch: query.refetch,
tablePreferences,
pageIndex, pageIndex,
pageSize, pageSize,
setPageIndex, setPageIndex: setPageIndexValue,
setPageSize: setPageSizeValue, setPageSize: setPageSizeValue,
search, search,
setSearchValue, setSearchValue,
sort,
setSort: setSortValue,
statusFilter: verifactuStatusFilter, statusFilter: verifactuStatusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
}; };

View File

@ -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 { ColumnDef } from "@tanstack/react-table";
import type * as React from "react"; import type * as React from "react";
@ -19,6 +23,9 @@ interface IssuedInvoicesGridProps {
onPageChange: (pageIndex: number) => void; onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void; onPageSizeChange: (size: number) => void;
sort: DataTableSort;
onSortChange?: (nextSort: DataTableSort) => void;
onRowClick?: (proformaId: string) => void; onRowClick?: (proformaId: string) => void;
} }
@ -33,6 +40,8 @@ export const IssuedInvoicesGrid = ({
pageSize, pageSize,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
sort,
onSortChange,
onRowClick, onRowClick,
}: IssuedInvoicesGridProps) => { }: IssuedInvoicesGridProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -109,11 +118,14 @@ export const IssuedInvoicesGrid = ({
isFetching={fetching} isFetching={fetching}
isSearching={searching} isSearching={searching}
manualPagination manualPagination
manualSorting
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
onSortChange={onSortChange}
onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined} onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined}
pageIndex={pageIndex} pageIndex={pageIndex}
pageSize={pageSize} pageSize={pageSize}
sort={sort}
topContent={topContent} topContent={topContent}
totalItems={totalItems} totalItems={totalItems}
/> />

View File

@ -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<typeof useIssuedInvoicesGridColumns> = [];
const Harness = () => {
columns = useIssuedInvoicesGridColumns();
return null;
};
renderToStaticMarkup(<Harness />);
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");
});

View File

@ -33,25 +33,20 @@ export function useIssuedInvoicesGridColumns(
() => [ () => [
// Nº // Nº
{ {
id: "invoiceReference",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
className="text-right tabular-nums justify-end" className="text-right tabular-nums justify-end"
column={column} column={column}
title={t("pages.issued_invoices.list.grid_columns.invoice_number")} title={t("pages.issued_invoices.list.grid_columns.invoice_reference")}
/> />
), ),
accessorKey: "invoiceReference", accessorKey: "invoiceReference",
/*cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right tabular-nums"> <div className="text-right tabular-nums">{row.original.invoiceReference}</div>
{row.original.series} ),
{row.original.invoiceNumber}
</div>
),*/
enableHiding: false, enableHiding: false,
enableSorting: true, enableSorting: true,
/*maxSize: 96,
size: 96,
minSize: 96,*/
meta: { meta: {
title: t("pages.issued_invoices.list.grid_columns.invoice_reference"), title: t("pages.issued_invoices.list.grid_columns.invoice_reference"),
cellClassName: "font-mono text-sm font-medium", cellClassName: "font-mono text-sm font-medium",
@ -192,7 +187,8 @@ export function useIssuedInvoicesGridColumns(
// Fecha factura // Fecha factura
{ {
accessorKey: "invoice_date", id: "invoiceDate",
accessorKey: "invoiceDate",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
className="text-left tabular-nums" className="text-left tabular-nums"
@ -205,7 +201,7 @@ export function useIssuedInvoicesGridColumns(
{DateHelper.format(row.original.invoiceDate)} {DateHelper.format(row.original.invoiceDate)}
</div> </div>
), ),
enableSorting: false, enableSorting: true,
size: 140, size: 140,
minSize: 120, minSize: 120,
meta: { meta: {

View File

@ -143,10 +143,12 @@ export const ListIssuedInvoicesPage = () => {
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onSortChange={listCtrl.setSort}
onRowClick={handleOpenDetail} onRowClick={handleOpenDetail}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
searching={isSearchPending} searching={isSearchPending}
sort={listCtrl.sort}
topContent={ topContent={
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">

View File

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

View File

@ -27,6 +27,7 @@ const IssuedInvoiceListRowAdapter = {
id: dto.id, id: dto.id,
companyId: dto.company_id, companyId: dto.company_id,
invoiceReference: dto.invoice_reference,
invoiceNumber: dto.invoice_number, invoiceNumber: dto.invoice_number,
status: dto.status as IssuedInvoiceStatus, status: dto.status as IssuedInvoiceStatus,
series: dto.series, series: dto.series,

View File

@ -13,6 +13,7 @@ export interface IssuedInvoiceListRow {
id: string; id: string;
companyId: string; companyId: string;
invoiceReference: string;
invoiceNumber: string; invoiceNumber: string;
status: IssuedInvoiceStatus; status: IssuedInvoiceStatus;
series: string; series: string;