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" },
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,

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 { 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<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);
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"]>>(
() => ({
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,
};

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 * 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}
/>

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º
{
id: "invoiceReference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums justify-end"
column={column}
title={t("pages.issued_invoices.list.grid_columns.invoice_number")}
title={t("pages.issued_invoices.list.grid_columns.invoice_reference")}
/>
),
accessorKey: "invoiceReference",
/*cell: ({ row }) => (
<div className="text-right tabular-nums">
{row.original.series}
{row.original.invoiceNumber}
</div>
),*/
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.original.invoiceReference}</div>
),
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 }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
@ -205,7 +201,7 @@ export function useIssuedInvoicesGridColumns(
{DateHelper.format(row.original.invoiceDate)}
</div>
),
enableSorting: false,
enableSorting: true,
size: 140,
minSize: 120,
meta: {

View File

@ -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={
<div className="space-y-5">
<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,
companyId: dto.company_id,
invoiceReference: dto.invoice_reference,
invoiceNumber: dto.invoice_number,
status: dto.status as IssuedInvoiceStatus,
series: dto.series,

View File

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