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

View File

@ -1,7 +1,7 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import * as React from "react"; import * as React from "react";
import { useDownloadInvoicePDFQuery } from "../hooks"; import { useDownloadInvoicePDFQuery } from "../../shared";
interface PendingDownload { interface PendingDownload {
id: string; 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 React from "react";
import { useDownloadInvoicePDFController } from "../../download-pdf/controller"; import { useDownloadInvoicePDFController } from "../../download-pdf/controller";
import type { IssuedInvoiceSummaryData } from "../../types"; import type { IssuedInvoiceListRow } from "../../shared";
import { useIssuedInvoiceListController } from "./use-issued-invoice-list.controller"; import { useIssuedInvoiceListController } from "./use-issued-invoice-list.controller";
@ -10,8 +10,8 @@ export function useIssuedInvoiceListPageController() {
const downloadPDFCtrl = useDownloadInvoicePDFController(); const downloadPDFCtrl = useDownloadInvoicePDFController();
const handleDownloadPDF = React.useCallback( const handleDownloadPDF = React.useCallback(
(issuedInvoice: IssuedInvoiceSummaryData) => { (issuedInvoice: IssuedInvoiceListRow) => {
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoice_number); downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoiceNumber);
}, },
[downloadPDFCtrl] [downloadPDFCtrl]
); );

View File

@ -1,51 +1,101 @@
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components"; import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { IssuedInvoiceSummaryDtoAdapter } from "../adapters"; import type {
import { useIssuedInvoiceListQuery } from "../hooks"; 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 = () => { export const useIssuedInvoiceListController = () => {
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState(""); 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 criteria = useMemo<NonNullable<ListIssuedInvoicesByCriteriaParams["criteria"]>>(
const baseFilters = () => ({
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : []; q: debouncedSearch || "",
return {
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex, pageNumber: pageIndex,
pageSize,
order: "desc", order: "desc",
orderBy: "invoice_date", orderBy: "invoice_date",
filters: baseFilters, filters: status === "all" ? [] : [{ field: "status", operator: "eq", value: status }],
}; }),
}, [pageSize, pageIndex, debouncedQ, status]); [debouncedSearch, pageIndex, pageSize, status]
const query = useIssuedInvoiceListQuery({ criteria });
const data = useMemo(
() => (query.data ? IssuedInvoiceSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]
); );
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 { return {
...query, data: query.data ?? EMPTY_ISSUED_INVOICES_LIST,
data, isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
pageIndex, pageIndex,
pageSize, pageSize,
search,
setPageIndex, setPageIndex,
setPageSize, setPageSize: setPageSizeValue,
search,
setSearchValue, setSearchValue,
setStatusFilter,
statusFilter,
setStatusFilter: setStatusFilterValue,
}; };
}; };

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import {
getVerifactuRecordStatusButtonVariant, getVerifactuRecordStatusButtonVariant,
getVerifactuRecordStatusColor, getVerifactuRecordStatusColor,
getVerifactuRecordStatusIcon, getVerifactuRecordStatusIcon,
} from "../../../types"; } from "../../../shared";
export type VerifactuStatusBadgeProps = { export type VerifactuStatusBadgeProps = {
status: string | VerifactuRecordStatus; // permitir cualquier valor status: string | VerifactuRecordStatus; // permitir cualquier valor
@ -21,21 +21,23 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger
<Badge render={
className={cn( <Badge
getVerifactuRecordStatusColor(normalizedStatus), className={cn(
"font-semibold", getVerifactuRecordStatusColor(normalizedStatus),
className "font-semibold",
)} className
variant={getVerifactuRecordStatusButtonVariant(normalizedStatus)} )}
> variant={getVerifactuRecordStatusButtonVariant(normalizedStatus)}
<Icon /> >
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, { <Icon />
defaultValue: status, {t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
})} defaultValue: status,
</Badge> })}
</TooltipTrigger> </Badge>
}
/>
<TooltipContent> <TooltipContent>
<p>{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.description`)}</p> <p>{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.description`)}</p>
</TooltipContent> </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 { useIssuedInvoiceListPageController } from "../../controllers";
import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks"; import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
export const IssuedInvoiceListPage = () => { export const ListIssuedInvoicesPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
listCtrl, listCtrl,
handleDownloadPDF, handleDownloadPDF,
@ -43,7 +43,7 @@ export const IssuedInvoiceListPage = () => {
widthClass: "w-[500px]", widthClass: "w-[500px]",
});*/ });*/
if (listCtrl.isError || !listCtrl.data) { if (listCtrl.isError) {
return ( return (
<AppContent> <AppContent>
<ErrorAlert <ErrorAlert
@ -56,7 +56,7 @@ export const IssuedInvoiceListPage = () => {
} }
return ( return (
<> <section>
<AppHeader> <AppHeader>
<PageHeader <PageHeader
description={t("pages.issued_invoices.list.description")} description={t("pages.issued_invoices.list.description")}
@ -73,7 +73,8 @@ export const IssuedInvoiceListPage = () => {
title={t("pages.issued_invoices.list.title")} title={t("pages.issued_invoices.list.title")}
/> />
</AppHeader> </AppHeader>
<AppContent>
<AppContent className="space-y-6">
<Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6"> <Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6">
<div> <div>
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle> <AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
@ -87,13 +88,17 @@ export const IssuedInvoiceListPage = () => {
</Alert> </Alert>
{/* Search and filters */} {/* 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 <SimpleSearchInput
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue} 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"> <SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" /> <FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} /> <SelectValue placeholder={t("filters.status")} />
@ -127,19 +132,20 @@ export const IssuedInvoiceListPage = () => {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto">
<IssuedInvoicesGrid <IssuedInvoicesGrid
columns={columns} columns={columns}
data={listCtrl.data} data={listCtrl.data}
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller // acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)} //onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
/> />
</div>
</AppContent> </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 type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; import { type DefaultError, useQuery } from "@tanstack/react-query";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import { getIssuedInvoiceListApi } from "../../list/api"; import { ListIssuedInvoicesAdapter } from "../adapters";
import type { IssuedInvoiceSummaryPage } from "../../types"; import { getListIssuedInvoicesByCriteria } from "../api";
import type { IssuedInvoiceList } from "../entities";
export const ISSUED_INVOICES_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [ import { LIST_ISSUED_INVOICES_QUERY_KEY } from "./keys";
"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 ?? "",
},
];
type IssuedInvoicesQueryOptions = { export interface IssuedInvoicesListQueryOptions {
enabled?: boolean; enabled?: boolean;
criteria?: CriteriaDTO; criteria?: Partial<CriteriaDTO>;
}; }
// Obtener todas las facturas // Obtener todas las facturas
export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesQueryOptions) => { export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesListQueryOptions) => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const enabled = options?.enabled ?? true; const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {}; const criteria = options?.criteria ?? {};
return useQuery<IssuedInvoiceSummaryPage, DefaultError>({ return useQuery<IssuedInvoiceList, DefaultError>({
queryKey: ISSUED_INVOICES_QUERY_KEY(criteria), queryKey: LIST_ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => getIssuedInvoiceListApi(dataSource, signal, criteria), queryFn: async ({ signal }) => {
const dto = await getListIssuedInvoicesByCriteria(dataSource, { signal, criteria });
return ListIssuedInvoicesAdapter.fromDto(dto);
},
enabled, enabled,
staleTime: 5000, placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
}); });
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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