Proformas: Ordenación y guardar preferencias

This commit is contained in:
David Arranz 2026-05-19 17:23:10 +02:00
parent 0aabff0d18
commit 00627096ed
17 changed files with 389 additions and 103 deletions

View File

@ -220,11 +220,19 @@ export class IssuedInvoiceRepository
const query = criteriaConverter.convert(criteria, { const query = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "reference", "description"], searchableFields: ["invoice_number", "reference", "description"],
mappings: { mappings: {
invoice_number: "CustomerInvoiceModel.invoice_number", invoice_date: "invoice_date",
reference: "CustomerInvoiceModel.reference", invoice_number: "invoice_number",
description: "CustomerInvoiceModel.description", reference: "reference",
description: "description",
recipient_name: "current_customer.name",
}, },
allowedFields: ["invoice_date", "id", "created_at"], sortableFields: [
"current_customer.name",
"invoice_number",
"invoice_date",
"id",
"created_at",
],
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, // fuerza error si ORDER BY no permitido strictMode: true, // fuerza error si ORDER BY no permitido

View File

@ -350,11 +350,19 @@ export class ProformaRepository
const query = criteriaConverter.convert(criteria, { const query = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "reference", "description"], searchableFields: ["invoice_number", "reference", "description"],
mappings: { mappings: {
invoice_number: "CustomerInvoiceModel.invoice_number", invoice_date: "invoice_date",
reference: "CustomerInvoiceModel.reference", invoice_number: "invoice_number",
description: "CustomerInvoiceModel.description", reference: "reference",
description: "description",
recipient_name: "current_customer.name",
}, },
allowedFields: ["invoice_date", "id", "created_at"], sortableFields: [
"current_customer.name",
"invoice_number",
"invoice_date",
"id",
"created_at",
],
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, // fuerza error si ORDER BY no permitido strictMode: true, // fuerza error si ORDER BY no permitido

View File

@ -150,12 +150,12 @@
"list": { "list": {
"title": "Customer proformas", "title": "Customer proformas",
"description": "List all customer proformas", "description": "List all customer proformas",
"grid_columns": { "columns": {
"invoice_number": "#", "invoice_number": "Num.",
"series": "Serie", "series": "Serie",
"reference": "Reference", "reference": "Reference",
"status": "Status", "status": "Status",
"invoice_date": "Proforma date", "invoice_date": "Date",
"operation_date": "Operation date", "operation_date": "Operation date",
"recipient": "Customer", "recipient": "Customer",
"recipient_tin": "TIN", "recipient_tin": "TIN",

View File

@ -151,12 +151,12 @@
"list": { "list": {
"title": "Proformas", "title": "Proformas",
"description": "Lista todas las proformas", "description": "Lista todas las proformas",
"grid_columns": { "columns": {
"invoice_number": "#", "invoice_number": "Num.",
"series": "Serie", "series": "Serie",
"reference": "Reference", "reference": "Reference",
"status": "Estado", "status": "Estado",
"invoice_date": "Fecha de proforma", "invoice_date": "Fecha",
"operation_date": "Fecha de operación", "operation_date": "Fecha de operación",
"recipient": "Cliente", "recipient": "Cliente",
"recipient_tin": "NIF/CIF", "recipient_tin": "NIF/CIF",

View File

@ -1,5 +1,10 @@
import { useDebounce } from "@erp/core/hooks"; import { useDebounce } from "@erp/core/hooks";
import { useDataTablePreferences } from "@repo/rdx-ui/components"; import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import {
type DataTableSort,
type DataTableSortDirection,
useDataTablePreferences,
} from "@repo/rdx-ui/components";
import { NumberHelper } from "@repo/rdx-utils"; import { NumberHelper } from "@repo/rdx-utils";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
@ -13,14 +18,41 @@ import {
type ProformaListStatusFilter = "all" | ProformaStatus; type ProformaListStatusFilter = "all" | ProformaStatus;
// Datos por defecto mientras se carga la consulta o en caso de error.
const EMPTY_PROFORMAS_LIST: ProformaList = { const EMPTY_PROFORMAS_LIST: ProformaList = {
items: [], items: [],
page: 0, page: INITIAL_PAGE_INDEX,
perPage: 5, perPage: INITIAL_PAGE_SIZE,
totalPages: 0, totalPages: 0,
totalItems: 0, totalItems: 0,
}; };
// Campos que se permiten ordenar para la lista de proformas (consulta).
type ProformaListSortField = "invoiceNumber" | "recipientName" | "invoiceDate";
type ProformaListApiSortField = "invoice_number" | "recipient_name" | "invoice_date";
const PROFORMA_LIST_SORT_FIELDS: Record<ProformaListSortField, ProformaListApiSortField> = {
invoiceNumber: "invoice_number",
recipientName: "recipient_name",
invoiceDate: "invoice_date",
};
const DEFAULT_API_SORT_FIELD: ProformaListApiSortField = "invoice_date";
const DEFAULT_SORT = {
field: "invoiceDate",
direction: "desc",
} satisfies DataTableSort;
const isProformaListSortField = (value: string | null): value is ProformaListSortField => {
return value === "invoiceNumber" || value === "recipientName" || value === "invoiceDate";
};
const isSortDirection = (value: string | null): value is DataTableSortDirection => {
return value === "asc" || value === "desc";
};
export const useListProformasController = () => { export const useListProformasController = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all");
@ -28,7 +60,7 @@ export const useListProformasController = () => {
const tablePreferences = useDataTablePreferences({ const tablePreferences = useDataTablePreferences({
storageKey: "proformas:list:grid", storageKey: "proformas:list:grid",
defaultPageSize: 5, defaultPageSize: EMPTY_PROFORMAS_LIST.perPage,
defaultColumnVisibility: { defaultColumnVisibility: {
reference: true, reference: true,
recipientName: true, recipientName: true,
@ -36,12 +68,17 @@ export const useListProformasController = () => {
totalAmountFmt: true, totalAmountFmt: true,
invoiceDate: true, invoiceDate: true,
}, },
defaultSort: DEFAULT_SORT,
}); });
const { pageSize: preferencesPageSize, setPageSize: setPageSizePreference } = tablePreferences; const { pageSize: preferencesPageSize, setPageSize: setPageSizePreference } = tablePreferences;
const { sort: preferencesSorting, setSort: setSortPreference } = tablePreferences;
// Parse page from URL (1-based) or default to 1 // Parse page from URL (1-based) or default to 1
const urlPage = NumberHelper.parsePositiveInteger(searchParams.get("page"), 1); const urlPage = NumberHelper.parsePositiveInteger(
searchParams.get("page"),
INITIAL_PAGE_INDEX + 1
);
const pageIndex = urlPage - 1; // Convert to 0-based for internal use const pageIndex = urlPage - 1; // Convert to 0-based for internal use
// Parse pageSize from URL or use preferences // Parse pageSize from URL or use preferences
@ -52,17 +89,37 @@ export const useListProformasController = () => {
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
// Criterios de ordenamiento
const urlSortFieldValue = searchParams.get("sortField");
const urlSortDirectionValue = searchParams.get("sortDirection");
const urlSort =
isProformaListSortField(urlSortFieldValue) && isSortDirection(urlSortDirectionValue)
? {
field: urlSortFieldValue,
direction: urlSortDirectionValue,
}
: undefined;
const sort = urlSort ?? preferencesSorting ?? DEFAULT_SORT;
const orderBy =
PROFORMA_LIST_SORT_FIELDS[sort.field as ProformaListSortField] ?? DEFAULT_API_SORT_FIELD;
const order = sort.direction;
// Construir criterios de consulta
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>( const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({ () => ({
q: debouncedSearch || "", q: debouncedSearch || "",
pageNumber: pageIndex, pageNumber: pageIndex,
pageSize, pageSize,
order: "desc", orderBy,
orderBy: "invoice_date", order,
filters: filters:
statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }], statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }],
}), }),
[debouncedSearch, pageIndex, pageSize, statusFilter] [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter]
); );
const query = useProformasListQuery({ criteria }); const query = useProformasListQuery({ criteria });
@ -96,7 +153,7 @@ export const useListProformasController = () => {
// Reset page to 1 when search changes // Reset page to 1 when search changes
setSearchParams((prev) => { setSearchParams((prev) => {
const params = new URLSearchParams(prev); const params = new URLSearchParams(prev);
params.set("page", "1"); params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL
return params; return params;
}); });
return nextValue; return nextValue;
@ -124,7 +181,7 @@ export const useListProformasController = () => {
// Reset page to 1 and update pageSize when it changes // Reset page to 1 and update pageSize when it changes
setSearchParams((prev) => { setSearchParams((prev) => {
const params = new URLSearchParams(prev); const params = new URLSearchParams(prev);
params.set("page", "1"); params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL
params.set("pageSize", String(value)); params.set("pageSize", String(value));
return params; return params;
}); });
@ -133,6 +190,31 @@ export const useListProformasController = () => {
[pageSize, setSearchParams, setPageSizePreference] [pageSize, setSearchParams, setPageSizePreference]
); );
const setSortValue = useCallback(
(nextSort: DataTableSort) => {
if (!isProformaListSortField(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, sort.field, sort.direction, setSortPreference]
);
return { return {
data: query.data ?? EMPTY_PROFORMAS_LIST, data: query.data ?? EMPTY_PROFORMAS_LIST,
isLoading: query.isLoading, isLoading: query.isLoading,
@ -153,6 +235,9 @@ export const useListProformasController = () => {
search, search,
setSearchValue, setSearchValue,
sort,
setSort: setSortValue,
statusFilter, statusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
}; };

View File

@ -1,4 +1,4 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
import { useTranslation } from "../../../../../i18n"; import { useTranslation } from "../../../../../i18n";
@ -16,6 +16,9 @@ interface ProformasGridProps {
onPageChange: (pageIndex: number) => void; onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void; onPageSizeChange: (size: number) => void;
sort: DataTableSort;
onSortChange?: (nextSort: DataTableSort) => void;
columnVisibility?: VisibilityState; columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>; onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
@ -24,15 +27,22 @@ interface ProformasGridProps {
export const ProformasGrid = ({ export const ProformasGrid = ({
data, data,
columns,
loading, loading,
fetching, fetching,
columns,
pageIndex, pageIndex,
pageSize, pageSize,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
sort,
onSortChange,
columnVisibility, columnVisibility,
onColumnVisibilityChange, onColumnVisibilityChange,
onRowClick, onRowClick,
}: ProformasGridProps) => { }: ProformasGridProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -57,12 +67,15 @@ export const ProformasGrid = ({
enablePagination enablePagination
enableRowSelection enableRowSelection
manualPagination manualPagination
manualSorting
onColumnVisibilityChange={onColumnVisibilityChange} onColumnVisibilityChange={onColumnVisibilityChange}
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
onSortChange={onSortChange}
//onRowClick={(row) => onRowClick?.(row.id)} //onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex} pageIndex={pageIndex}
pageSize={pageSize} pageSize={pageSize}
sort={sort}
totalItems={totalItems} totalItems={totalItems}
/> />
); );

View File

@ -1,3 +1,4 @@
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils"; import { DateHelper } from "@repo/rdx-utils";
import { import {
Button, Button,
@ -9,7 +10,6 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { import {
ArrowUpDownIcon,
ExternalLinkIcon, ExternalLinkIcon,
FileTextIcon, FileTextIcon,
PencilIcon, PencilIcon,
@ -46,6 +46,7 @@ export function useProformasGridColumns(
): ColumnDef<ProformaListRow, unknown>[] { ): ColumnDef<ProformaListRow, unknown>[] {
const { t } = useTranslation(); const { t } = useTranslation();
// Todas las columnas deben tener un id
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>( return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
() => [ () => [
{ {
@ -70,37 +71,52 @@ export function useProformasGridColumns(
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
/> />
), ),
enableSorting: false,
enableHiding: false, enableHiding: false,
enableSorting: false,
}, },
{ {
id: "invoiceDate",
accessorKey: "invoiceDate",
enableHiding: false,
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("pages.proformas.list.columns.invoice_date")}
/>
),
cell: ({ row }) => DateHelper.format(row.original.invoiceDate),
},
{
id: "series",
accessorKey: "series", accessorKey: "series",
header: "Serie", header: "Serie",
enableHiding: true, enableHiding: true,
enableSorting: false,
}, },
{ {
id: "invoiceNumber",
accessorKey: "invoiceNumber", accessorKey: "invoiceNumber",
header: ({ column }) => {
return (
<Button
className="px-0"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Num.
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
enableHiding: false, enableHiding: false,
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("pages.proformas.list.columns.invoice_number")}
/>
),
meta: { meta: {
cellClassName: "font-medium", cellClassName: "font-medium",
}, },
}, },
{ {
id: "status",
accessorKey: "status", accessorKey: "status",
header: "Estado",
enableHiding: true, enableHiding: true,
enableSorting: false,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.proformas.list.columns.status")} />
),
cell: ({ row }) => { cell: ({ row }) => {
const proforma = row.original; const proforma = row.original;
@ -139,19 +155,16 @@ export function useProformasGridColumns(
// Cliente // Cliente
{ {
id: "recipientName",
accessorKey: "recipientName", accessorKey: "recipientName",
header: ({ column }) => { enableHiding: false,
return ( enableSorting: true,
<Button header: ({ column }) => (
className="px-0" <DataTableColumnHeader
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} column={column}
variant="ghost" title={t("pages.proformas.list.columns.recipient")}
> />
Cliente ),
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const proforma = row.original; const proforma = row.original;
return ( return (
@ -165,26 +178,18 @@ export function useProformasGridColumns(
cellClassName: "max-w-128", cellClassName: "max-w-128",
}, },
}, },
{ {
accessorKey: "invoiceDate", id: "reference",
header: ({ column }) => {
return (
<Button
className="px-0"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Fecha prof.
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => DateHelper.format(row.original.invoiceDate),
},
{
accessorKey: "reference", accessorKey: "reference",
header: "Referencia",
enableHiding: true, enableHiding: true,
enableSorting: false,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("pages.proformas.list.columns.reference")}
/>
),
cell: ({ row }) => <div className="truncate">{row.original.reference}</div>, cell: ({ row }) => <div className="truncate">{row.original.reference}</div>,
meta: { meta: {
cellClassName: "hidden lg:table-cell max-w-16", cellClassName: "hidden lg:table-cell max-w-16",
@ -193,40 +198,56 @@ export function useProformasGridColumns(
}, },
{ {
id: "subtotalAmountFmt",
accessorKey: "subtotalAmountFmt", accessorKey: "subtotalAmountFmt",
header: () => "Subtotal", enableHiding: true,
enableSorting: false,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("pages.proformas.list.columns.subtotal_amount")}
/>
),
meta: { meta: {
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium", cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
headerClassName: "hidden 2xl:table-cell text-right", headerClassName: "hidden 2xl:table-cell text-right",
}, },
}, },
{ {
id: "totalDiscountAmountFmt",
accessorKey: "totalDiscountAmountFmt", accessorKey: "totalDiscountAmountFmt",
header: "Dtos", header: "Dtos",
enableSorting: false,
meta: { meta: {
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium", cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
headerClassName: "hidden 2xl:table-cell text-right", headerClassName: "hidden 2xl:table-cell text-right",
}, },
}, },
{ {
id: "taxableAmountFmt",
accessorKey: "taxableAmountFmt", accessorKey: "taxableAmountFmt",
header: "Base Imp.", header: "Base Imp.",
enableSorting: false,
meta: { meta: {
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium", cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
headerClassName: "hidden 2xl:table-cell text-right", headerClassName: "hidden 2xl:table-cell text-right",
}, },
}, },
{ {
id: "taxesAmountFmt",
accessorKey: "taxesAmountFmt", accessorKey: "taxesAmountFmt",
header: "Impuestos", header: "Impuestos",
enableSorting: false,
meta: { meta: {
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium", cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
headerClassName: "hidden 2xl:table-cell text-right", headerClassName: "hidden 2xl:table-cell text-right",
}, },
}, },
{ {
id: "totalAmountFmt",
accessorKey: "totalAmountFmt", accessorKey: "totalAmountFmt",
header: "Total", header: "Total",
enableSorting: false,
meta: { meta: {
cellClassName: "hidden xl:table-cell text-right tabular-nums font-semibold", cellClassName: "hidden xl:table-cell text-right tabular-nums font-semibold",
headerClassName: "hidden xl:table-cell text-right", headerClassName: "hidden xl:table-cell text-right",
@ -257,7 +278,12 @@ export function useProformasGridColumns(
render={ render={
<Button <Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary" className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={() => actionHandlers.onEditClick?.(proforma)} onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onEditClick?.(proforma);
}}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
@ -279,9 +305,12 @@ export function useProformasGridColumns(
render={ render={
<Button <Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary" className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={() => onClick={(event) => {
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0]) event.preventDefault();
} event.stopPropagation();
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0]);
}}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
@ -305,7 +334,12 @@ export function useProformasGridColumns(
render={ render={
<Button <Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary" className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={() => actionHandlers.onIssueClick?.(proforma)} onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onIssueClick?.(proforma);
}}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
@ -326,8 +360,10 @@ export function useProformasGridColumns(
render={ render={
<Button <Button
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer" className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
onClick={(e) => { onClick={(event) => {
e.preventDefault(); event.preventDefault();
event.stopPropagation();
actionHandlers.onDeleteClick?.(proforma); actionHandlers.onDeleteClick?.(proforma);
}} }}
size="icon" size="icon"

View File

@ -98,8 +98,10 @@ export const ListProformasPage = () => {
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")} onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
sort={listCtrl.sort}
/> />
{/* Explicación técnica */} {/* Explicación técnica */}

View File

@ -61,11 +61,11 @@ export const ProformaUpdateEditorForm = ({
onKeyDown={preventEnterKeySubmitForm} onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<div className="grid grid-cols-1 gap-4 md:grid-cols-12"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<ProformaUpdateHeaderEditor className="md:col-span-8" disabled={isSubmitting} /> <ProformaUpdateHeaderEditor className="xl:col-span-9" disabled={isSubmitting} />
<ProformaUpdateRecipientEditor <ProformaUpdateRecipientEditor
className="md:col-span-4" className="xl:col-span-3"
disabled={isSubmitting} disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick} onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick} onCreateCustomerClick={onCreateCustomerClick}
@ -76,9 +76,9 @@ export const ProformaUpdateEditorForm = ({
<ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} taxCtrl={taxCtrl} /> <ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} taxCtrl={taxCtrl} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-12"> <div className="grid grid-cols-1 gap-4 md:grid-cols-12">
<ProformaUpdateTaxEditor className="md:col-span-6" taxCtrl={taxCtrl} /> <ProformaUpdateTaxEditor className="md:col-span-4" taxCtrl={taxCtrl} />
<ProformaTotalsSummary <ProformaTotalsSummary
className="md:col-span-6" className="md:col-span-8"
currency={currencyCode} currency={currencyCode}
globalDiscountField={ globalDiscountField={
<PercentageField <PercentageField

View File

@ -6,6 +6,7 @@ import {
TextAreaField, TextAreaField,
TextField, TextField,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { FileTextIcon } from "lucide-react"; import { FileTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -34,7 +35,7 @@ export const ProformaUpdateHeaderEditor = ({
> >
<FormSectionGrid> <FormSectionGrid>
<SelectField <SelectField
className="md:col-span-2" className="md:col-span-3"
disabled={disabled} disabled={disabled}
label={t("form_fields.proformas.series.label")} label={t("form_fields.proformas.series.label")}
name="series" name="series"
@ -42,9 +43,33 @@ export const ProformaUpdateHeaderEditor = ({
readOnly={readOnly} readOnly={readOnly}
/> />
<DatePickerField <TextField
className="md:col-span-6"
disabled={disabled}
label={t("form_fields.proformas.invoice_number.label")}
maxLength={16}
name="reference"
placeholder={t("form_fields.proformas.invoice_number.placeholder")}
readOnly={readOnly}
rightIcon={
<Badge className="text-sm" variant="secondary">
Autom.
</Badge>
}
/>
<SelectField
className="md:col-span-3" className="md:col-span-3"
disabled={disabled} disabled={disabled}
label={t("form_fields.proformas.currency_code.label")}
name="currencyCode"
placeholder={t("form_fields.proformas.currency_code.placeholder")}
readOnly={readOnly}
/>
<DatePickerField
className="md:col-span-3 md:col-start-1"
disabled={disabled}
label={t("form_fields.proformas.invoice_date.label")} label={t("form_fields.proformas.invoice_date.label")}
name="invoiceDate" name="invoiceDate"
placeholder={t("form_fields.proformas.invoice_date.placeholder")} placeholder={t("form_fields.proformas.invoice_date.placeholder")}
@ -62,7 +87,7 @@ export const ProformaUpdateHeaderEditor = ({
/> />
<TextField <TextField
className="md:col-span-4" className="md:col-span-6"
disabled={disabled} disabled={disabled}
label={t("form_fields.proformas.reference.label")} label={t("form_fields.proformas.reference.label")}
maxLength={256} maxLength={256}

View File

@ -64,7 +64,7 @@ export const ProformaUpdatePage = () => {
return ( return (
<FormProvider {...updateCtrl.form}> <FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<AppHeader className="mx-auto max-w-5xl space-y-4"> <AppHeader className="mx-auto max-w-7xl space-y-4">
<PageHeader <PageHeader
description={t("pages.proformas.update.description")} description={t("pages.proformas.update.description")}
onBackClick={() => navigateBack()} onBackClick={() => navigateBack()}
@ -87,7 +87,7 @@ export const ProformaUpdatePage = () => {
/> />
</AppHeader> </AppHeader>
<AppContent className="mx-auto max-w-5xl space-y-4"> <AppContent className="mx-auto max-w-7xl space-y-4">
{updateCtrl.isUpdateError && ( {updateCtrl.isUpdateError && (
<ErrorAlert <ErrorAlert
message={ message={

View File

@ -242,7 +242,7 @@ export class CustomerRepository
"email_primary", "email_primary",
"mobile_primary", "mobile_primary",
], ],
allowedFields: [ sortableFields: [
"name", "name",
"trade_name", "trade_name",
"reference", "reference",

View File

@ -124,17 +124,18 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value; const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value;
const direction = criteria.order.orderType.value.toUpperCase(); const direction = criteria.order.orderType.value.toUpperCase();
const allowedFields = params.allowedFields ?? ["id", "created_at"]; const sortableFields = params.sortableFields ?? ["id", "created_at"];
const strict = params.strictMode ?? false; const strict = params.strictMode ?? false;
if (!allowedFields.includes(field)) { if (!sortableFields.includes(field)) {
const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in allowedFields).`; const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in sortableFields).`;
if (strict) throw new Error(msg); if (strict) throw new Error(msg);
console.warn(msg); console.warn(msg);
return; return;
} }
appendOrder(options, [[field, direction]] as OrderItem[]); const orderItem = this.buildOrderItem(field, direction);
appendOrder(options, orderItem as OrderItem[]);
} }
/** Paginación estándar */ /** Paginación estándar */
@ -182,4 +183,18 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
return `\`${tableAlias}\`.\`${field}\``; return `\`${tableAlias}\`.\`${field}\``;
} }
private buildOrderItem(field: string, direction: string): OrderItem {
if (field.includes(".")) {
const [associationAlias, column] = field.split(".");
if (!associationAlias || !column) {
throw new Error(`[CriteriaToSequelizeConverter] Invalid nested ORDER BY field '${field}'.`);
}
return [{ as: associationAlias }, column, direction] as OrderItem;
}
return [field, direction];
}
} }

View File

@ -11,7 +11,7 @@ export type CriteriaMappings = Record<string, string>;
/** /**
* Parámetros de conversión FindOptions (Sequelize). * Parámetros de conversión FindOptions (Sequelize).
* - allowedFields: lista blanca de campos ordenables (deben estar indexados). * - sortableFields: lista blanca de campos ordenables (deben estar indexados).
* - searchableFields: columnas FULLTEXT (deben tener índice FT en BD). * - searchableFields: columnas FULLTEXT (deben tener índice FT en BD).
* - enableFullText: activa MATCH ... AGAINST si true. * - enableFullText: activa MATCH ... AGAINST si true.
* - mappings: mapeo CriteriaSQL. * - mappings: mapeo CriteriaSQL.
@ -20,7 +20,7 @@ export type CriteriaMappings = Record<string, string>;
* - strictMode?: true = lanza error si orden no permitido * - strictMode?: true = lanza error si orden no permitido
*/ */
export interface ConvertParams { export interface ConvertParams {
allowedFields?: string[]; // p.ej. ['invoice_date','id','created_at'] sortableFields?: string[]; // p.ej. ['invoice_date','id','created_at']
searchableFields?: string[]; // p.ej. ['reference','description','notes'] searchableFields?: string[]; // p.ej. ['reference','description','notes']
enableFullText?: boolean; // default: false enableFullText?: boolean; // default: false
mappings?: CriteriaMappings; // default: {} mappings?: CriteriaMappings; // default: {}

View File

@ -41,7 +41,7 @@ export function DataTableColumnHeader<TData, TValue>({
type="button" type="button"
variant="ghost" variant="ghost"
> >
<span>{title}</span> {title}
{column.getIsSorted() === "desc" ? ( {column.getIsSorted() === "desc" ? (
<ArrowDownIcon /> <ArrowDownIcon />
) : column.getIsSorted() === "asc" ? ( ) : column.getIsSorted() === "asc" ? (

View File

@ -65,6 +65,13 @@ export type DataTableMeta<TData> = TableMeta<TData> & {
bulkOps?: DataTableBulkRowOps<TData>; bulkOps?: DataTableBulkRowOps<TData>;
}; };
export type DataTableSortDirection = "asc" | "desc";
export interface DataTableSort {
field: string;
direction: DataTableSortDirection;
}
export interface DataTableProps<TData, TValue> { export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@ -81,6 +88,11 @@ export interface DataTableProps<TData, TValue> {
getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string; getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string;
// Soporte para ordenamiento server-side
sort?: DataTableSort;
onSortChange?: (sort: DataTableSort) => void;
manualSorting?: boolean;
// Soporte para paginación server-side // Soporte para paginación server-side
manualPagination?: boolean; manualPagination?: boolean;
pageIndex?: number; // 0-based pageIndex?: number; // 0-based
@ -111,6 +123,10 @@ export function DataTable<TData, TValue>({
getRowId, getRowId,
sort: sorting,
onSortChange: onSortingChange,
manualSorting,
manualPagination, manualPagination,
pageIndex = 0, pageIndex = 0,
totalItems, totalItems,
@ -122,7 +138,7 @@ export function DataTable<TData, TValue>({
const { t } = useTranslation(); const { t } = useTranslation();
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]); const [internalSorting, setInternalSorting] = React.useState<DataTableSort | undefined>();
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({}); const [colSizes, setColSizes] = React.useState<ColumnSizingState>({});
@ -130,10 +146,57 @@ export function DataTable<TData, TValue>({
{} {}
); );
// Visibilidad
const resolvedColumnVisibility = columnVisibility ?? internalColumnVisibility; const resolvedColumnVisibility = columnVisibility ?? internalColumnVisibility;
const handleColumnVisibilityChange = onColumnVisibilityChange ?? setInternalColumnVisibility; const handleColumnVisibilityChange = onColumnVisibilityChange ?? setInternalColumnVisibility;
// Ordenación
const resolvedSorting = sorting ?? internalSorting;
const resolvedSortingState = React.useMemo<SortingState>(
() =>
resolvedSorting
? [
{
id: resolvedSorting.field,
desc: resolvedSorting.direction === "desc",
},
]
: [],
[resolvedSorting]
);
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
const nextSorting = typeof updater === "function" ? updater(resolvedSortingState) : updater;
const next = nextSorting[0];
if (!next?.id) {
return;
}
const nextSort: DataTableSort = {
field: next.id,
direction: next.desc ? "desc" : "asc",
};
if (
resolvedSorting?.field === nextSort.field &&
resolvedSorting.direction === nextSort.direction
) {
return;
}
if (onSortingChange) {
onSortingChange(nextSort);
return;
}
setInternalSorting(nextSort);
};
const isManualSorting = manualSorting ?? false;
const isManualPagination = manualPagination ?? false;
// Configuración TanStack // Configuración TanStack
const table = useReactTable({ const table = useReactTable({
data, data,
@ -151,14 +214,15 @@ export function DataTable<TData, TValue>({
state: { state: {
columnSizing: colSizes, columnSizing: colSizes,
sorting, sorting: resolvedSortingState,
columnVisibility: resolvedColumnVisibility, columnVisibility: resolvedColumnVisibility,
rowSelection, rowSelection,
columnFilters, columnFilters,
pagination: { pageIndex, pageSize }, pagination: { pageIndex, pageSize },
}, },
manualPagination, manualSorting: isManualSorting,
manualPagination: isManualPagination,
autoResetPageIndex: false, autoResetPageIndex: false,
pageCount: manualPagination ? Math.max(1, Math.ceil((totalItems ?? 0) / pageSize)) : undefined, pageCount: manualPagination ? Math.max(1, Math.ceil((totalItems ?? 0) / pageSize)) : undefined,
@ -176,16 +240,17 @@ export function DataTable<TData, TValue>({
} }
}, },
onSortingChange: handleSortingChange,
enableRowSelection, enableRowSelection,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: handleColumnVisibilityChange, onColumnVisibilityChange: handleColumnVisibilityChange,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(), getPaginationRowModel: isManualPagination ? undefined : getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: isManualSorting ? undefined : getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(), getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedUniqueValues: getFacetedUniqueValues(),
}); });

View File

@ -1,21 +1,28 @@
import type { OnChangeFn, VisibilityState } from "@tanstack/react-table"; import type { OnChangeFn, VisibilityState } from "@tanstack/react-table";
import * as React from "react"; import * as React from "react";
import type { DataTableSort } from "../data-table.tsx";
// Es preferible utilizar tipos de TanStack/Table
// para guardar las preferencias de la tabla.
export interface DataTablePreferences { export interface DataTablePreferences {
pageSize: number; pageSize: number;
columnVisibility: VisibilityState; columnVisibility: VisibilityState;
sort?: DataTableSort;
} }
interface UseDataTablePreferencesParams { interface UseDataTablePreferencesParams {
storageKey: string; storageKey: string;
defaultPageSize: number; defaultPageSize: number;
defaultColumnVisibility?: VisibilityState; defaultColumnVisibility?: VisibilityState;
defaultSort?: DataTableSort;
} }
export const useDataTablePreferences = ({ export const useDataTablePreferences = ({
storageKey, storageKey,
defaultPageSize, defaultPageSize,
defaultColumnVisibility = {}, defaultColumnVisibility = {},
defaultSort = { field: "", direction: "asc" },
}: UseDataTablePreferencesParams) => { }: UseDataTablePreferencesParams) => {
const [preferences, setPreferences] = React.useState<DataTablePreferences>(() => { const [preferences, setPreferences] = React.useState<DataTablePreferences>(() => {
try { try {
@ -25,6 +32,7 @@ export const useDataTablePreferences = ({
return { return {
pageSize: defaultPageSize, pageSize: defaultPageSize,
columnVisibility: defaultColumnVisibility, columnVisibility: defaultColumnVisibility,
sort: defaultSort,
}; };
} }
@ -36,11 +44,13 @@ export const useDataTablePreferences = ({
...defaultColumnVisibility, ...defaultColumnVisibility,
...parsed.columnVisibility, ...parsed.columnVisibility,
}, },
sort: parsed.sort ?? defaultSort,
}; };
} catch { } catch {
return { return {
pageSize: defaultPageSize, pageSize: defaultPageSize,
columnVisibility: defaultColumnVisibility, columnVisibility: defaultColumnVisibility,
sort: defaultSort,
}; };
} }
}); });
@ -82,9 +92,28 @@ export const useDataTablePreferences = ({
[persist] [persist]
); );
const setSort = React.useCallback(
(sort: DataTableSort) => {
setPreferences((previous) => {
const next: DataTablePreferences = {
...previous,
sort,
};
persist(next);
return next;
});
},
[persist]
);
return { return {
pageSize: preferences.pageSize, pageSize: preferences.pageSize,
columnVisibility: preferences.columnVisibility, columnVisibility: preferences.columnVisibility,
sort: preferences.sort,
setSort,
setPageSize, setPageSize,
setColumnVisibility, setColumnVisibility,
}; };