This commit is contained in:
David Arranz 2026-04-12 21:12:10 +02:00
parent 71b9e857e7
commit 8a8282ddc3
48 changed files with 828 additions and 653 deletions

View File

@ -3,7 +3,7 @@ import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
const ProformaLayout = lazy(() =>
import("./proformas/shared").then((m) => ({ default: m.ProformaLayout }))
import("./proformas/shared/ui").then((m) => ({ default: m.ProformaLayout }))
);
const ProformasListPage = lazy(() =>
@ -19,7 +19,7 @@ const ProformaUpdatePage = lazy(() =>
);
const IssuedInvoicesLayout = lazy(() =>
import("./issued-invoices/shared").then((m) => ({ default: m.IssuedInvoicesLayout }))
import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
);
const IssuedInvoiceListPage = lazy(() =>

View File

@ -1,7 +1,7 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import * as React from "react";
import { useDownloadInvoicePDFQuery } from "../hooks";
import { useDownloadInvoicePDFQuery } from "../../shared";
interface PendingDownload {
id: string;

View File

@ -1 +0,0 @@
export * from "./issued-invoice-summary-dto.adapter";

View File

@ -1,71 +0,0 @@
import { MoneyDTOHelper, formatCurrency } from "@erp/core";
import type {
IssuedInvoiceSummaryData,
IssuedInvoiceSummaryPage,
IssuedInvoiceSummaryPageData,
} from "../../types";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const IssuedInvoiceSummaryDtoAdapter = {
fromDto(pageDto: IssuedInvoiceSummaryPage, context?: unknown): IssuedInvoiceSummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
(summaryDto) =>
({
...summaryDto,
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
/*discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage
),*/
discount_amount: MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.total_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
//taxes: dto.taxes,
}) as unknown as IssuedInvoiceSummaryData
),
};
},
};

View File

@ -1,18 +0,0 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { IssuedInvoiceSummaryPage } from "../../types";
export async function getIssuedInvoiceListApi(
dataSource: IDataSource,
signal: AbortSignal,
criteria: CriteriaDTO
) {
const response = dataSource.getList<IssuedInvoiceSummaryPage>("issued-invoices", {
signal,
...criteria,
});
//return mapIssuedInvoiceList(raw);
return response;
}

View File

@ -1 +0,0 @@
export * from "./get-issued-invoice-list.api";

View File

@ -1,7 +1,7 @@
import React from "react";
import { useDownloadInvoicePDFController } from "../../download-pdf/controller";
import type { IssuedInvoiceSummaryData } from "../../types";
import type { IssuedInvoiceListRow } from "../../shared";
import { useIssuedInvoiceListController } from "./use-issued-invoice-list.controller";
@ -10,8 +10,8 @@ export function useIssuedInvoiceListPageController() {
const downloadPDFCtrl = useDownloadInvoicePDFController();
const handleDownloadPDF = React.useCallback(
(issuedInvoice: IssuedInvoiceSummaryData) => {
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoice_number);
(issuedInvoice: IssuedInvoiceListRow) => {
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoiceNumber);
},
[downloadPDFCtrl]
);

View File

@ -1,51 +1,101 @@
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { IssuedInvoiceSummaryDtoAdapter } from "../adapters";
import { useIssuedInvoiceListQuery } from "../hooks";
import type {
IssuedInvoiceList,
IssuedInvoiceStatus,
ListIssuedInvoicesByCriteriaParams,
} from "../../shared";
import { useIssuedInvoiceListQuery } from "../../shared/";
type IssuedInvoiceListStatusFilter = "all" | IssuedInvoiceStatus;
const EMPTY_ISSUED_INVOICES_LIST: IssuedInvoiceList = {
items: [],
page: 0,
perPage: 5,
totalPages: 0,
totalItems: 0,
};
export const useIssuedInvoiceListController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const [statusFilter, setStatusFilter] = useState<IssuedInvoiceListStatusFilter>("all");
const debouncedQ = useDebounce(search, 300);
const debouncedSearch = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : [];
return {
q: debouncedQ || "",
pageSize,
const criteria = useMemo<NonNullable<ListIssuedInvoicesByCriteriaParams["criteria"]>>(
() => ({
q: debouncedSearch || "",
pageNumber: pageIndex,
pageSize,
order: "desc",
orderBy: "invoice_date",
filters: baseFilters,
};
}, [pageSize, pageIndex, debouncedQ, status]);
const query = useIssuedInvoiceListQuery({ criteria });
const data = useMemo(
() => (query.data ? IssuedInvoiceSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]
filters: status === "all" ? [] : [{ field: "status", operator: "eq", value: status }],
}),
[debouncedSearch, pageIndex, pageSize, status]
);
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
const query = useIssuedInvoiceListQuery({ criteria });
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
const setStatusFilterValue = (value: string) => {
const nextValue = (value || "all") as IssuedInvoiceListStatusFilter;
setStatusFilter((prev) => {
if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return nextValue;
});
};
const setSearchValue = (value: string) => {
const nextValue = value.trim().replace(/\s+/g, " ");
setSearch((prev) => {
if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return nextValue;
});
};
const setPageSizeValue = (value: number) => {
setPageSize((prev) => {
if (prev === value) return prev;
// Sólo si el tamaño de página realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return value;
});
};
return {
...query,
data,
data: query.data ?? EMPTY_ISSUED_INVOICES_LIST,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
pageIndex,
pageSize,
search,
setPageIndex,
setPageSize,
setPageSize: setPageSizeValue,
search,
setSearchValue,
setStatusFilter,
statusFilter,
setStatusFilter: setStatusFilterValue,
};
};

View File

@ -1,42 +1,37 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../../i18n";
import type { IssuedInvoiceSummaryData, IssuedInvoiceSummaryPageData } from "../../../../types";
import type { IssuedInvoiceList, IssuedInvoiceListRow } from "../../../../shared";
export type InvoiceUpdateCompProps = {
data: IssuedInvoiceSummaryPageData;
loading?: boolean;
interface IssuedInvoicesGridProps {
data?: IssuedInvoiceList;
loading: boolean;
fetching?: boolean;
columns: ColumnDef<IssuedInvoiceSummaryData, unknown>[];
columns: ColumnDef<IssuedInvoiceListRow, unknown>[];
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void;
onRowClick?: (
row: IssuedInvoiceSummaryPageData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
onRowClick?: (proformaId: string) => void;
}
// Create new GridExample component
export const IssuedInvoicesGrid = ({
data,
loading,
fetching,
columns,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
onRowClick,
}: InvoiceUpdateCompProps) => {
}: IssuedInvoicesGridProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = data;
const { items, totalItems } = data || { items: [], totalItems: 0 };
// Navegación accesible (click o teclado)
/* const goToRow = useCallback(
@ -90,36 +85,29 @@ export const IssuedInvoicesGrid = ({
if (loading) {
return (
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
</div>
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
}
// Render principal
return (
<>
{/*<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>*/}
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={handleRowClick}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
{/*</div>*/}
</>
<DataTable
columns={columns}
data={items}
enablePagination={false}
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}
/>
);
};

View File

@ -5,6 +5,7 @@ import {
ButtonGroup,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
@ -18,21 +19,21 @@ import * as React from "react";
import QrCode from "react-qr-code";
import { useTranslation } from "../../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../../types";
import type { IssuedInvoiceListRow } from "../../../../shared";
import { VerifactuStatusBadge } from "../../components";
type GridActionHandlers = {
onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void;
onDownloadPdf?: (issuedInvoice: IssuedInvoiceListRow) => void;
pdfDownloadingId?: string | null;
isPdfDownloading?: boolean;
};
export function useIssuedInvoicesGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<IssuedInvoiceSummaryData, unknown>[] {
): ColumnDef<IssuedInvoiceListRow, unknown>[] {
const { t } = useTranslation();
return React.useMemo<ColumnDef<IssuedInvoiceSummaryData>[]>(
return React.useMemo<ColumnDef<IssuedInvoiceListRow, unknown>[]>(
() => [
// Nº
{
@ -47,7 +48,7 @@ export function useIssuedInvoicesGridColumns(
cell: ({ row }) => (
<div className="text-right tabular-nums">
{row.original.series}
{row.original.invoice_number}
{row.original.invoiceNumber}
</div>
),
enableHiding: false,
@ -106,11 +107,13 @@ export function useIssuedInvoicesGridColumns(
<QrCodeIcon className="size-8 text-muted-foreground" />
) : (
<Tooltip>
<TooltipTrigger>
<a href={verifactu.url} rel="noopener" target="_blank">
<QrCodeIcon className="size-8" />
</a>
</TooltipTrigger>
<TooltipTrigger
render={
<a href={verifactu.url} rel="noopener" target="_blank">
<QrCodeIcon className="size-8" />
</a>
}
/>
<TooltipContent className="m-0 p-3">
<QrCode className="bg-white p-8" value={verifactu.url} />
</TooltipContent>
@ -192,7 +195,7 @@ export function useIssuedInvoicesGridColumns(
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
{formatDate(row.original.invoiceDate)}
</div>
),
enableSorting: false,
@ -214,7 +217,7 @@ export function useIssuedInvoicesGridColumns(
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
{formatDate(row.original.operationDate)}
</div>
),
enableSorting: false,
@ -237,7 +240,7 @@ export function useIssuedInvoicesGridColumns(
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
{row.original.subtotalAmountFmt}
</div>
),
enableSorting: false,
@ -260,7 +263,7 @@ export function useIssuedInvoicesGridColumns(
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
{row.original.totalDiscountAmountFmt}
</div>
),
enableSorting: false,
@ -282,7 +285,7 @@ export function useIssuedInvoicesGridColumns(
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
<div className="font-medium text-right tabular-nums">{row.original.taxesAmountFmt}</div>
),
enableSorting: false,
size: 120,
@ -303,9 +306,7 @@ export function useIssuedInvoicesGridColumns(
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
<div className="font-semibold text-right tabular-nums">{row.original.totalAmountFmt}</div>
),
enableSorting: false,
size: 140,
@ -343,25 +344,27 @@ export function useIssuedInvoicesGridColumns(
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger>
<Button
className={"size-8"}
disabled={isPDFLoading || !isCompleted}
onClick={(e) => {
stop(e);
isCompleted ? actionHandlers.onDownloadPdf?.(issuedInvoice) : null;
}}
size="icon"
variant="ghost"
>
{isPDFLoading ? (
<Spinner className="size-4 cursor-progress" />
) : (
<FileDownIcon className="size-4 cursor-pointer" />
)}
<span className="sr-only">Descargar PDF</span>
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className={"size-8"}
disabled={isPDFLoading || !isCompleted}
onClick={(e) => {
stop(e);
isCompleted ? actionHandlers.onDownloadPdf?.(issuedInvoice) : null;
}}
size="icon"
variant="ghost"
>
{isPDFLoading ? (
<Spinner className="size-4 cursor-progress" />
) : (
<FileDownIcon className="size-4 cursor-pointer" />
)}
<span className="sr-only">Descargar PDF</span>
</Button>
}
/>
<TooltipContent>Descargar PDF</TooltipContent>
</Tooltip>
@ -369,31 +372,35 @@ export function useIssuedInvoicesGridColumns(
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => actionHandlers.onDownloadPdf?.(issuedInvoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onClick={stop}>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => actionHandlers.onDownloadPdf?.(issuedInvoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onClick={stop}>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}

View File

@ -7,7 +7,7 @@ import {
getVerifactuRecordStatusButtonVariant,
getVerifactuRecordStatusColor,
getVerifactuRecordStatusIcon,
} from "../../../types";
} from "../../../shared";
export type VerifactuStatusBadgeProps = {
status: string | VerifactuRecordStatus; // permitir cualquier valor
@ -21,21 +21,23 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
return (
<Tooltip>
<TooltipTrigger>
<Badge
className={cn(
getVerifactuRecordStatusColor(normalizedStatus),
"font-semibold",
className
)}
variant={getVerifactuRecordStatusButtonVariant(normalizedStatus)}
>
<Icon />
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
defaultValue: status,
})}
</Badge>
</TooltipTrigger>
<TooltipTrigger
render={
<Badge
className={cn(
getVerifactuRecordStatusColor(normalizedStatus),
"font-semibold",
className
)}
variant={getVerifactuRecordStatusButtonVariant(normalizedStatus)}
>
<Icon />
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
defaultValue: status,
})}
</Badge>
}
/>
<TooltipContent>
<p>{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.description`)}</p>
</TooltipContent>

View File

@ -1 +1 @@
export * from "./issued-invoice-list-page";
export * from "./list-issued-invoices-page";

View File

@ -18,10 +18,10 @@ import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceListPageController } from "../../controllers";
import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
export const IssuedInvoiceListPage = () => {
export const ListIssuedInvoicesPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
listCtrl,
handleDownloadPDF,
@ -43,7 +43,7 @@ export const IssuedInvoiceListPage = () => {
widthClass: "w-[500px]",
});*/
if (listCtrl.isError || !listCtrl.data) {
if (listCtrl.isError) {
return (
<AppContent>
<ErrorAlert
@ -56,7 +56,7 @@ export const IssuedInvoiceListPage = () => {
}
return (
<>
<section>
<AppHeader>
<PageHeader
description={t("pages.issued_invoices.list.description")}
@ -73,7 +73,8 @@ export const IssuedInvoiceListPage = () => {
title={t("pages.issued_invoices.list.title")}
/>
</AppHeader>
<AppContent>
<AppContent className="space-y-6">
<Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6">
<div>
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
@ -87,13 +88,17 @@ export const IssuedInvoiceListPage = () => {
</Alert>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
<Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter}
>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
@ -127,19 +132,20 @@ export const IssuedInvoiceListPage = () => {
</SelectContent>
</Select>
</div>
<IssuedInvoicesGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
<div className="min-h-0 flex-1 overflow-auto">
<IssuedInvoicesGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
</div>
</AppContent>
</>
</section>
);
};

View File

@ -0,0 +1 @@
export * from "./list-issued-invoice.adapter";

View File

@ -0,0 +1,101 @@
import { MoneyDTOHelper, formatCurrency } from "@erp/core";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common";
import type { IssuedInvoiceList, IssuedInvoiceListRow, IssuedInvoiceStatus } from "../entities";
import type { VerifactuRecordStatus } from "../entities/verifactu-record-status.entity";
export const ListIssuedInvoicesAdapter = {
fromDto(dto: ListIssuedInvoicesResponseDTO): IssuedInvoiceList {
return {
items: dto.items.map(IssuedInvoiceListRowAdapter.fromDto),
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
totalItems: dto.total_items,
};
},
};
type ListIssuedInvoicesItemDTO = ListIssuedInvoicesResponseDTO["items"][number];
const IssuedInvoiceListRowAdapter = {
fromDto(dto: ListIssuedInvoicesItemDTO): IssuedInvoiceListRow {
return {
id: dto.id,
companyId: dto.company_id,
invoiceNumber: dto.invoice_number,
status: dto.status as IssuedInvoiceStatus,
series: dto.series,
invoiceDate: dto.invoice_date,
operationDate: dto.operation_date,
languageCode: dto.language_code,
currencyCode: dto.currency_code,
reference: dto.reference,
description: dto.description,
recipient: {
id: dto.customer_id,
tin: dto.recipient.tin,
name: dto.recipient.name,
street: dto.recipient.street,
street2: dto.recipient.street2,
city: dto.recipient.city,
province: dto.recipient.province,
postalCode: dto.recipient.postal_code,
country: dto.recipient.country,
},
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
subtotalAmountFmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.subtotal_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
totalDiscountAmount: MoneyDTOHelper.toNumber(dto.total_discount_amount),
totalDiscountAmountFmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.total_discount_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxableAmountFmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxable_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount),
taxesAmountFmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxes_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
totalAmount: MoneyDTOHelper.toNumber(dto.total_amount),
totalAmountFmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.total_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
//linkedProformaId: dto.,
verifactu: {
status: dto.verifactu.status as unknown as VerifactuRecordStatus,
url: dto.verifactu.url,
qr_code: dto.verifactu.qr_code,
},
};
},
};

View File

@ -0,0 +1 @@
export * from "./list-issued-invoices-by-criteria.api";

View File

@ -0,0 +1,32 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common";
/**
* Recupera una lista de facturas del sistema utilizando la
* fuente de datos proporcionada y los criterios de búsqueda especificados.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para listar las facturas, incluyendo los criterios de búsqueda.
* @returns Una promesa que resuelve con una lista de facturas que cumplen con los criterios especificados.
* @throws Error si la recuperación de la lista de facturas falla.
*/
export type ListIssuedInvoicesByCriteriaParams = {
criteria?: CriteriaDTO;
signal?: AbortSignal;
};
export type ListIssuedInvoicesResult = ListIssuedInvoicesResponseDTO;
export function getListIssuedInvoicesByCriteria(
dataSource: IDataSource,
params: ListIssuedInvoicesByCriteriaParams
): Promise<ListIssuedInvoicesResult> {
const { criteria, signal } = params || { criteria: undefined, signal: undefined };
return dataSource.getList<ListIssuedInvoicesResponseDTO>("issued-invoices", {
signal,
...criteria,
});
}

View File

@ -0,0 +1,4 @@
export * from "./issued-invoice-list.entity";
export * from "./issued-invoice-list-row.entity";
export * from "./issued-invoice-status.entity";
export * from "./verifactu-record-status.entity";

View File

@ -0,0 +1,47 @@
import type { IssuedInvoiceRecipient } from "./issued-invoice-recipient.entity";
import type { IssuedInvoiceStatus } from "./issued-invoice-status.entity";
import type { VerifactuRecord } from "./verifactu-record.entity";
/**
* Interface que representa una fila de la lista de
* facturas en el sistema, adaptada desde la respuesta de la API.
* Contiene los campos justos para mostrar
* la información básica de cada factura en la lista.
*/
export interface IssuedInvoiceListRow {
id: string;
companyId: string;
invoiceNumber: string;
status: IssuedInvoiceStatus;
series: string;
invoiceDate: string;
operationDate: string;
languageCode: string;
currencyCode: string;
reference: string;
description: string;
recipient: IssuedInvoiceRecipient;
subtotalAmount: number;
subtotalAmountFmt: string;
totalDiscountAmount: number;
totalDiscountAmountFmt: string;
taxableAmount: number;
taxableAmountFmt: string;
taxesAmount: number;
taxesAmountFmt: string;
totalAmount: number;
totalAmountFmt: string;
verifactu: VerifactuRecord;
}

View File

@ -0,0 +1,14 @@
import type { IssuedInvoiceListRow } from "./issued-invoice-list-row.entity";
/**
* Interface que representa la respuesta paginada de una lista de proformas,
* adaptada desde la respuesta de la API.
*/
export interface IssuedInvoiceList {
items: IssuedInvoiceListRow[];
totalPages: number;
totalItems: number;
page: number;
perPage: number;
}

View File

@ -0,0 +1,18 @@
/**
* Interface que representa el destinatario de una factura en el sistema,
* adaptada desde la respuesta de la API.
*/
export interface IssuedInvoiceRecipient {
id: string;
name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postalCode: string;
country: string;
}

View File

@ -0,0 +1,26 @@
/**
* Enumeración que representa
* los posibles estados de una proforma en el sistema.
*/
export enum ISSUED_INVOICE_STATUS {
DRAFT = "draft",
SENT = "sent",
APPROVED = "approved",
REJECTED = "rejected",
ISSUED = "issued",
}
// Transiciones válidas según reglas del dominio
export const ISSUED_INVOICE_STATUS_TRANSITIONS: Record<
ISSUED_INVOICE_STATUS,
ISSUED_INVOICE_STATUS[]
> = {
[ISSUED_INVOICE_STATUS.DRAFT]: [ISSUED_INVOICE_STATUS.SENT],
[ISSUED_INVOICE_STATUS.SENT]: [ISSUED_INVOICE_STATUS.APPROVED, ISSUED_INVOICE_STATUS.REJECTED],
[ISSUED_INVOICE_STATUS.APPROVED]: [ISSUED_INVOICE_STATUS.ISSUED, ISSUED_INVOICE_STATUS.DRAFT],
[ISSUED_INVOICE_STATUS.REJECTED]: [ISSUED_INVOICE_STATUS.DRAFT],
[ISSUED_INVOICE_STATUS.ISSUED]: [],
};
export type IssuedInvoiceStatus = `${ISSUED_INVOICE_STATUS}`;

View File

@ -0,0 +1,7 @@
import type { VerifactuRecordStatus } from "./verifactu-record-status.entity";
export interface VerifactuRecord {
status: VerifactuRecordStatus;
url: string;
qr_code: string;
}

View File

@ -0,0 +1,24 @@
import type { QueryKey } from "@tanstack/react-query";
import type { ListIssuedInvoicesRequestDTO } from "../../../../common";
/**
* Prefijo base para listados
*/
export const LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX = ["issued_invoices"] as const;
/**
* Query key para listado de facturas
*/
export const LIST_ISSUED_INVOICES_QUERY_KEY = (criteria?: ListIssuedInvoicesRequestDTO): QueryKey =>
[
...LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX,
{
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",
order: criteria?.order ?? "",
},
] as const;

View File

@ -1,39 +1,31 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import { type DefaultError, useQuery } from "@tanstack/react-query";
import { getIssuedInvoiceListApi } from "../../list/api";
import type { IssuedInvoiceSummaryPage } from "../../types";
import { ListIssuedInvoicesAdapter } from "../adapters";
import { getListIssuedInvoicesByCriteria } from "../api";
import type { IssuedInvoiceList } from "../entities";
export const ISSUED_INVOICES_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"issued_invoices",
{
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",
order: criteria?.order ?? "",
},
];
import { LIST_ISSUED_INVOICES_QUERY_KEY } from "./keys";
type IssuedInvoicesQueryOptions = {
export interface IssuedInvoicesListQueryOptions {
enabled?: boolean;
criteria?: CriteriaDTO;
};
criteria?: Partial<CriteriaDTO>;
}
// Obtener todas las facturas
export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesQueryOptions) => {
export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesListQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<IssuedInvoiceSummaryPage, DefaultError>({
queryKey: ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => getIssuedInvoiceListApi(dataSource, signal, criteria),
return useQuery<IssuedInvoiceList, DefaultError>({
queryKey: LIST_ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
const dto = await getListIssuedInvoicesByCriteria(dataSource, { signal, criteria });
return ListIssuedInvoicesAdapter.fromDto(dto);
},
enabled,
staleTime: 5000,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
});
};

View File

@ -1 +1,5 @@
export * from "./adapters";
export * from "./api";
export * from "./entities";
export * from "./hooks";
export * from "./ui";

View File

@ -1,5 +1,5 @@
import type { PropsWithChildren } from "react";
export const IssuedInvoicesLayout = ({ children }: PropsWithChildren) => {
return <div>{children}</div>;
return <div className="space-y-4">{children}</div>;
};

View File

@ -1,3 +0,0 @@
export * from "./issued-invoice.api.schema";
export * from "./issued-invoice-summary.web.schema";
export * from "./verifactu-record-status";

View File

@ -1,25 +0,0 @@
import type { IssuedInvoiceSummary, IssuedInvoiceSummaryPage } from "./issued-invoice.api.schema";
export type IssuedInvoiceSummaryData = IssuedInvoiceSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
discount_percentage_fmt: string;
discount_percentage: number;
discount_amount_fmt: string;
discount_amount: number;
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amount_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
total_amount: number;
};
export type IssuedInvoiceSummaryPageData = IssuedInvoiceSummaryPage & {
items: IssuedInvoiceSummaryData[];
};

View File

@ -1,22 +0,0 @@
import {
GetIssuedInvoiceByIdResponseSchema,
type ListIssuedInvoicesResponseDTO,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssuedInvoices
export const IssuedInvoiceSchema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssuedInvoice = z.infer<typeof IssuedInvoiceSchema>;
export type IssuedInvoiceRecipient = IssuedInvoice["recipient"];
export type IssuedInvoiceItem = ArrayElement<IssuedInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export type IssuedInvoiceSummaryPage = Omit<ListIssuedInvoicesResponseDTO, "metadata">;
export type IssuedInvoiceSummary = Omit<
ArrayElement<IssuedInvoiceSummaryPage["items"]>,
"metadata"
>;

View File

@ -101,18 +101,20 @@ export function useProformasGridColumns(
{isIssued && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
className="size-6 text-foreground hover:text-primary"
size="icon"
variant="ghost"
>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className="size-6 text-foreground hover:text-primary"
size="icon"
variant="ghost"
>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
</Button>
}
/>
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -235,17 +237,19 @@ export function useProformasGridColumns(
{!isIssued && actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onEditClick?.(proforma)}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onEditClick?.(proforma)}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
}
/>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -255,19 +259,21 @@ export function useProformasGridColumns(
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}>
<Tooltip>
<TooltipTrigger>
<Button
className="size-8 cursor-pointer"
onClick={() =>
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() =>
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
}
/>
<TooltipContent>
Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
</TooltipContent>
@ -279,16 +285,18 @@ export function useProformasGridColumns(
{!isIssued && isApproved && actionHandlers.onIssueClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onIssueClick?.(proforma)}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onIssueClick?.(proforma)}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
</Button>
}
/>
<TooltipContent>Emitir a factura</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -298,19 +306,21 @@ export function useProformasGridColumns(
{!isIssued && actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
className="size-8 text-destructive hover:text-destructive cursor-pointer"
onClick={(e) => {
e.preventDefault();
actionHandlers.onDeleteClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
className="size-8 text-destructive hover:text-destructive cursor-pointer"
onClick={(e) => {
e.preventDefault();
actionHandlers.onDeleteClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -1,2 +1 @@
export * from "./initials";
export * from "./proforma-status-badge";

View File

@ -21,15 +21,17 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
return (
<Tooltip>
<TooltipTrigger>
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
>
<Icon />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</Badge>
</TooltipTrigger>
<TooltipTrigger
render={
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
>
<Icon />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</Badge>
}
/>
<TooltipContent>
<p>{t(`catalog.proformas.status.${normalizedStatus}.description`)}</p>
</TooltipContent>

View File

@ -15,7 +15,7 @@ export const LIST_PROFORMAS_QUERY_KEY = (criteria?: ProformasListRequestDTO): Qu
...LIST_PROFORMAS_QUERY_KEY_PREFIX,
{
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 10,
pageSize: criteria?.pageSize ?? 5,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",

View File

@ -5,6 +5,7 @@ import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@ -152,51 +153,55 @@ export function useCustomersGridColumns(
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button aria-label={t("pages.list.actions.more")} size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button aria-label={t("pages.list.actions.more")} size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("pages.list.grid_columns.actions")}</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuLabel>{t("pages.list.grid_columns.actions")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onViewClick?.(customer)}>
{t("pages.list.actions.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewClick?.(customer)}>
{t("pages.list.actions.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditClick?.(customer)}>
{t("pages.list.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditClick?.(customer)}>
{t("pages.list.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!website}
onClick={() =>
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
}
>
{t("pages.list.actions.visit_website")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!website}
onClick={() =>
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
}
>
{t("pages.list.actions.visit_website")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!email_primary}
onClick={() => navigator.clipboard.writeText(email_primary)}
>
{t("pages.list.actions.copy_email")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!email_primary}
onClick={() => navigator.clipboard.writeText(email_primary)}
>
{t("pages.list.actions.copy_email")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteClick?.(customer)}
>
{t("pages.list.actions.delete")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteClick?.(customer)}
>
{t("pages.list.actions.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -30,11 +30,13 @@ export const CustomerStatusBadge = ({ status }: { status: string }) => {
return (
<Tooltip>
<TooltipTrigger>
<div className={cn("flex-none rounded-full p-1", statusClass)}>
<div className="size-2 rounded-full bg-current" />
</div>
</TooltipTrigger>
<TooltipTrigger
render={
<div className={cn("flex-none rounded-full p-1", statusClass)}>
<div className="size-2 rounded-full bg-current" />
</div>
}
/>
<TooltipContent>{contentTxt}</TooltipContent>
</Tooltip>
);

View File

@ -2,6 +2,7 @@ import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
@ -31,38 +32,42 @@ export function DataTableColumnHeader<TData, TValue>({
return (
<div className={cn("flex items-center gap-2 ", className)}>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
//className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
className="-ml-3 h-8 data-[state=open]:bg-accent cursor-pointer text-foreground"
size="sm"
type="button"
variant="ghost"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon />
) : (
<ChevronsUpDownIcon />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button
//className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
className="-ml-3 h-8 data-[state=open]:bg-accent cursor-pointer text-foreground"
size="sm"
type="button"
variant="ghost"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon />
) : (
<ChevronsUpDownIcon />
)}
</Button>
}
/>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon />
{t("components.datatable.asc")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon />
{t("components.datatable.desc")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOffIcon />
{t("components.datatable.hide")}
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon />
{t("components.datatable.asc")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon />
{t("components.datatable.desc")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOffIcon />
{t("components.datatable.hide")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -47,7 +47,8 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
table.setPageIndex(nextIndex);
};
const handlePageSizeChange = (size: string) => {
const handlePageSizeChange = (size: string | null) => {
if (!size) return;
table.setPageSize(Number(size));
};

View File

@ -113,34 +113,38 @@ export function DataTableToolbar<TData>({
{!readOnly && meta?.bulkOps?.moveSelectedUp && (
<Tooltip>
<TooltipTrigger>
<Button
aria-label={t("components.datatable.actions.move_up")}
onClick={handleMoveSelectedUp}
size="sm"
type="button"
variant="outline"
>
<ArrowUpIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
aria-label={t("components.datatable.actions.move_up")}
onClick={handleMoveSelectedUp}
size="sm"
type="button"
variant="outline"
>
<ArrowUpIcon aria-hidden="true" className="size-4" />
</Button>
}
/>
<TooltipContent>{t("components.datatable.actions.move_up")}</TooltipContent>
</Tooltip>
)}
{!readOnly && meta?.bulkOps?.moveSelectedDown && (
<Tooltip>
<TooltipTrigger>
<Button
aria-label={t("components.datatable.actions.move_down")}
onClick={handleMoveSelectedDown}
size="sm"
type="button"
variant="outline"
>
<ArrowDownIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
aria-label={t("components.datatable.actions.move_down")}
onClick={handleMoveSelectedDown}
size="sm"
type="button"
variant="outline"
>
<ArrowDownIcon aria-hidden="true" className="size-4" />
</Button>
}
/>
<TooltipContent>{t("components.datatable.actions.move_down")}</TooltipContent>
</Tooltip>
)}
@ -164,12 +168,14 @@ export function DataTableToolbar<TData>({
<Separator className="h-6 mx-1 bg-muted/50" orientation="vertical" />
<Tooltip>
<TooltipTrigger>
<Button onClick={handleClearSelection} size="sm" type="button" variant="outline">
<ScanIcon aria-hidden="true" className="size-4 mr-1" />
<span>{t("components.datatable.actions.clear_selection")}</span>
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button onClick={handleClearSelection} size="sm" type="button" variant="outline">
<ScanIcon aria-hidden="true" className="size-4 mr-1" />
<span>{t("components.datatable.actions.clear_selection")}</span>
</Button>
}
/>
<TooltipContent>{t("components.datatable.actions.clear_selection")}</TooltipContent>
</Tooltip>
</>

View File

@ -5,6 +5,7 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
@ -18,36 +19,41 @@ export function DataTableViewOptions<TData>({ table }: { table: Table<TData> })
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
className="ml-auto hidden h-8 lg:flex gap-2 items"
size="sm"
type="button"
variant="outline"
>
<Settings2Icon />
{t("components.datatable_view_options.columns_button")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button
className="ml-auto hidden h-8 lg:flex gap-2 items"
size="sm"
type="button"
variant="outline"
>
<Settings2Icon />
{t("components.datatable_view_options.columns_button")}
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("components.datatable_view_options.toggle_columns")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnLabel(column)}
</DropdownMenuCheckboxItem>
);
})}
<DropdownMenuGroup>
<DropdownMenuLabel>
{t("components.datatable_view_options.toggle_columns")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnLabel(column)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -1,13 +1,6 @@
"use client";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
TableBody,
TableCell,
Table as TableComp,
@ -80,7 +73,6 @@ export interface DataTableProps<TData, TValue> {
enablePagination?: boolean;
pageSize?: number;
enableRowSelection?: boolean;
EditorComponent?: React.ComponentType<{ row: TData; index: number; onClose: () => void }>;
getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string;
@ -105,7 +97,6 @@ export function DataTable<TData, TValue>({
enablePagination = true,
pageSize = 10,
enableRowSelection = false,
EditorComponent,
getRowId,
@ -125,7 +116,6 @@ export function DataTable<TData, TValue>({
React.useState<VisibilityState>(inititalcolumnVisibility);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({});
const [editIndex, setEditIndex] = React.useState<number | null>(null);
// Configuración TanStack
const table = useReactTable({
@ -139,7 +129,7 @@ export function DataTable<TData, TValue>({
getRowId ??
((originalRow: TData, i: number) => {
const row = originalRow as { id?: string | number };
return row.id !== undefined ? String(row.id) : String(i);
return row.id === undefined ? String(i) : String(row.id);
}),
state: {
@ -183,8 +173,6 @@ export function DataTable<TData, TValue>({
getFacetedUniqueValues: getFacetedUniqueValues(),
});
const handleCloseEditor = React.useCallback(() => setEditIndex(null), []);
// Render principal
return (
<div className="transition-[max-height] duration-300 ease-in-out">
@ -192,9 +180,9 @@ export function DataTable<TData, TValue>({
<DataTableToolbar showViewOptions={!readOnly} table={table} />
<div className="overflow-hidden rounded-md border">
<TableComp className="w-full text-sm">
<TableComp>
{/* CABECERA */}
<TableHeader className="sticky top-0 z-10">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {
@ -230,9 +218,6 @@ export function DataTable<TData, TValue>({
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
onDoubleClick={
readOnly || onRowClick ? undefined : () => setEditIndex(rowIndex)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
onRowClick?.(row.original, rowIndex, e as any);
@ -284,32 +269,6 @@ export function DataTable<TData, TValue>({
)}
</TableComp>
</div>
{/* Editor modal */}
{EditorComponent && editIndex !== null && (
<Dialog onOpenChange={handleCloseEditor} open>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
</DialogHeader>
<div className="mt-4">
<EditorComponent
index={editIndex}
onClose={handleCloseEditor}
row={data[editIndex]!}
/>
</div>
<DialogFooter>
<Button onClick={handleCloseEditor} type="button" variant="secondary">
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</div>
);

View File

@ -1,74 +1,75 @@
import {
TableBody,
TableCell,
Table as TableComp,
TableHead,
TableHeader,
TableRow,
TableBody,
TableCell,
Table as TableComp,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
// SkeletonTable.tsx
import * as React from "react";
import { SkeletonDataTableFooter } from './skeleton-data-table-footer.tsx';
import { SkeletonDataTableFooter } from "./skeleton-data-table-footer.tsx";
export type SkeletonTableProps = {
columns?: number;
rows?: number;
stickyHeader?: boolean;
showFooter?: boolean;
footerProps?: { pageIndex?: number; pageSize?: number; totalItems?: number };
}
columns?: number;
rows?: number;
stickyHeader?: boolean;
showFooter?: boolean;
footerProps?: { pageIndex?: number; pageSize?: number; totalItems?: number };
};
// Componente Skeleton de tabla genérico
export const SkeletonDataTable = ({
columns = 6,
rows = 10,
stickyHeader = true,
showFooter = true,
footerProps,
columns = 6,
rows = 10,
stickyHeader = true,
showFooter = true,
footerProps,
}: SkeletonTableProps) => {
// Genera arrays para mapear
const cols = React.useMemo(() => Array.from({ length: columns }), [columns]);
const rws = React.useMemo(() => Array.from({ length: rows }), [rows]);
// Genera arrays para mapear
const cols = React.useMemo(() => Array.from({ length: columns }), [columns]);
const rws = React.useMemo(() => Array.from({ length: rows }), [rows]);
return (
<div className="overflow-hidden rounded-md border bg-background" role="status" aria-busy="true">
<TableComp className="w-full text-sm">
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted" : ""}>
<TableRow>
{cols.map((_, i) => (
<TableHead key={`sk-th-${i}`}>
<div className="h-4 w-24 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
</TableHead>
))}
</TableRow>
</TableHeader>
return (
<div aria-busy="true" className="overflow-hidden rounded-md border bg-background" role="status">
<TableComp className="w-full text-sm">
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted" : ""}>
<TableRow>
{cols.map((_, i) => (
<TableHead key={`sk-th-${i}`}>
<div className="h-4 w-24 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rws.map((_, r) => (
<TableRow key={`sk-tr-${r}`}>
{cols.map((_, c) => (
<TableCell key={`sk-td-${r}-${c}`} className="align-top">
<div
className={[
"h-4 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none",
c === 0 ? "w-36" : "w-full",
"my-2",
].join(" ")}
style={{ maxWidth: c % 3 === 0 ? "14rem" : c % 3 === 1 ? "10rem" : "100%" }}
/>
{c > 0 && (
<div className="mt-2 h-3 w-1/2 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableComp>
<TableBody>
{rws.map((_, r) => (
<TableRow key={`sk-tr-${r}`}>
{cols.map((_, c) => (
<TableCell className="align-top" key={`sk-td-${r}-${c}`}>
<div
className={[
"h-4 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none",
c === 0 ? "w-36" : "w-full",
"my-2",
].join(" ")}
style={{ maxWidth: c % 3 === 0 ? "14rem" : c % 3 === 1 ? "10rem" : "100%" }}
/>
{c > 0 && (
<div className="mt-2 h-3 w-1/2 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableComp>
{showFooter && <SkeletonDataTableFooter {...footerProps} />}
{showFooter && <SkeletonDataTableFooter {...footerProps} />}
<span className="sr-only">Loading table</span>
</div>
);
}
<span className="sr-only">Loading table</span>
</div>
);
};

View File

@ -2,7 +2,6 @@ import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Separator,
SidebarTrigger,
} from "@repo/shadcn-ui/components";
@ -22,12 +21,10 @@ export function AppTopbar() {
<div className="flex items-center gap-1.5">
<ProfileDropdown
trigger={
<Button className="size-9.5" size="icon" variant="ghost">
<Avatar className="size-9.5 rounded-md">
<AvatarImage src="https://cdn.shadcnstudio.com/ss-assets/avatar/avatar-1.png" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
</Button>
<Avatar className="size-9.5 rounded-md">
<AvatarImage src="https://cdn.shadcnstudio.com/ss-assets/avatar/avatar-1.png" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
}
/>
</div>

View File

@ -3,7 +3,6 @@ export const LogoVerifactu = ({ color = "black", width = "100%", ...props }) =>
<div {...props}>
<svg
{...props}
height="auto"
preserveAspectRatio="xMidYMid meet"
style={{
color,