Compare commits

...

2 Commits

Author SHA1 Message Date
c1399cd67d Facturas de cliente 2025-11-23 11:46:35 +01:00
6e0900afae Facturas de cliente 2025-11-23 11:04:03 +01:00
36 changed files with 571 additions and 341 deletions

View File

@ -1,3 +0,0 @@
export * from "../proformas/adapters/proforma-dto.adapter";
export * from "./invoice-resume-dto.adapter";

View File

@ -1,63 +0,0 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { InvoiceSummaryFormData } from "../schemas/invoice-resume.form.schema";
import type { CustomerInvoiceSummary } from "../schemas/invoices.api.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const invoiceResumeDtoToFormAdapter = {
fromDto(dtos: CustomerInvoiceSummary[], context?: any) {
return dtos.map(
(dto) =>
({
...dto,
subtotal_amount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.subtotal_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(dto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(dto.discount_percentage),
discount_amount: MoneyDTOHelper.toNumber(dto.discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.discount_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxable_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(dto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxes_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
total_amount: MoneyDTOHelper.toNumber(dto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.total_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
//taxes: dto.taxes,
}) as unknown as InvoiceSummaryFormData
);
},
};

View File

@ -15,7 +15,7 @@ const ProformasListPage = lazy(() =>
);
const IssuedInvoiceListPage = lazy(() =>
import("./issued-invoices/pages").then((m) => ({ default: m.IssuedInvoiceListPage }))
import("./issued-invoices/list").then((m) => ({ default: m.IssuedInvoiceListPage }))
);
/*const CustomerInvoiceAdd = lazy(() =>

View File

@ -18,9 +18,8 @@ export const useInvoiceQuery = (invoiceId?: string, options?: InvoiceQueryOption
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!invoiceId) {
if (!invoiceId) throw new Error("invoiceId is required");
}
return await dataSource.getOne<Proforma>("customer-invoices", invoiceId, {
signal,
});

View File

@ -0,0 +1,14 @@
import type { IDataSource } from "@erp/core/client";
export async function downloadInvoicePDFApi(
dataSource: IDataSource,
invoiceId: string,
params?: Record<string, unknown>
): Promise<Blob> {
return dataSource.custom<Blob>({
...params,
path: `issued-invoices/${invoiceId}/report`,
method: "get",
responseType: "blob",
});
}

View File

@ -0,0 +1 @@
export * from "./download-invoice-pdf.api";

View File

@ -0,0 +1 @@
export * from "./use-download-invoice-pdf.controller";

View File

@ -0,0 +1,54 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import * as React from "react";
import { useDownloadInvoicePDFQuery } from "../hooks";
interface PendingDownload {
id: string;
invoice_number: string;
}
export function useDownloadInvoicePDFController() {
const [pending, setPending] = React.useState<PendingDownload | null>(null);
const { data, isFetching, refetch } = useDownloadInvoicePDFQuery(pending?.id);
// Efecto: cuando hay blob + pending, disparamos la descarga
React.useEffect(() => {
if (!(pending && data)) return;
const blob = data;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `invoice-${pending.invoice_number}.pdf`;
a.click();
URL.revokeObjectURL(url);
showSuccessToast(
"Descarga iniciada",
`La factura ${pending.invoice_number} se está descargando.`
);
setPending(null);
}, [data, pending]);
const download = (id: string, invoice_number: string) => {
setPending({ id, invoice_number });
// refetch se dispara en un efecto, o aquí si prefieres:
refetch().catch((err) => {
showErrorToast(
"Error al descargar",
err instanceof Error ? err.message : "Error desconocido"
);
setPending(null);
});
};
return {
download, // (id, reference) => void
isLoading: isFetching,
loadingId: pending?.id,
};
}

View File

@ -0,0 +1 @@
export * from "./use-download-invoice-pdf-query";

View File

@ -0,0 +1,55 @@
// features/invoices/view-pdf/hooks/use-download-invoice-pdf.ts
import { useDataSource } from "@erp/core/hooks";
import { type QueryKey, useQuery } from "@tanstack/react-query";
import { downloadInvoicePDFApi } from "../api";
export const ISSUED_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issued_invoice", id] as const;
type DownloadInvoicePDFOptions = {
enabled?: boolean;
};
export function useDownloadInvoicePDFQuery(
invoiceId?: string,
options?: DownloadInvoicePDFOptions
) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!invoiceId;
return useQuery({
queryKey: ISSUED_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => {
if (!invoiceId) throw new Error("invoiceId is required");
const { signal } = context;
return await downloadInvoicePDFApi(dataSource, invoiceId, {
signal,
});
},
enabled,
staleTime: 0,
refetchOnWindowFocus: false,
});
}
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -4,13 +4,14 @@ import type {
IssuedInvoiceSummaryData,
IssuedInvoiceSummaryPage,
IssuedInvoiceSummaryPageData,
} from "../schema";
} from "../../types";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const IssuedInvoiceSummaryDtoAdapter = {
fromDto(pageDto: IssuedInvoiceSummaryPage, context?: unknown): IssuedInvoiceSummaryPageData {
console.log(pageDto);
return {
...pageDto,
items: pageDto.items.map(

View File

@ -0,0 +1,18 @@
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

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

View File

@ -0,0 +1,2 @@
export * from "./use-issued-invoice-list.controller";
export * from "./use-issued-invoice-list-page.controller.ts";

View File

@ -0,0 +1,28 @@
import React from "react";
import { useDownloadInvoicePDFController } from "../../download-pdf/controller";
import type { IssuedInvoiceSummaryData } from "../../types";
import { useIssuedInvoiceListController } from "./use-issued-invoice-list.controller";
export function useIssuedInvoiceListPageController() {
const listCtrl = useIssuedInvoiceListController();
const downloadPDFCtrl = useDownloadInvoicePDFController();
const handleDownloadPDF = React.useCallback(
(issuedInvoice: IssuedInvoiceSummaryData) => {
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoice_number);
},
[downloadPDFCtrl]
);
return {
listCtrl,
downloadPDFCtrl,
handleDownloadPDF,
pdfDownloadingId: downloadPDFCtrl.loadingId,
isPDFDownloading: downloadPDFCtrl.isLoading,
};
}

View File

@ -0,0 +1,51 @@
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";
export const useIssuedInvoiceListController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : [];
return {
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
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]
);
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
return {
...query,
data,
pageIndex,
pageSize,
search,
setPageIndex,
setPageSize,
setSearchValue,
setStatusFilter,
};
};

View File

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

View File

@ -0,0 +1,38 @@
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 { IssuedInvoiceSummaryPage } from "../../types";
import { getIssuedInvoiceListApi } from "../api";
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 ?? "",
},
];
type IssuedInvoicesQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO;
};
// Obtener todas las facturas
export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesQueryOptions) => {
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),
enabled,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./issued-invoices-grid";

View File

@ -0,0 +1,2 @@
export * from "./issued-invoices-grid";
export * from "./use-issued-invoices-grid-columns";

View File

@ -0,0 +1,125 @@
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";
export type InvoiceUpdateCompProps = {
data: IssuedInvoiceSummaryPageData;
loading?: boolean;
columns: ColumnDef<IssuedInvoiceSummaryData, unknown>[];
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onRowClick?: (
row: IssuedInvoiceSummaryPageData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const IssuedInvoicesGrid = ({
data,
loading,
columns,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
onRowClick,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = data;
// Navegación accesible (click o teclado)
/* const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);
const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
);
const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<any>) => {
if (!e.data) return;
const ev = e.event;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
ev.preventDefault();
goToRow(e.data.id);
}
if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
ev.preventDefault();
goToRow(e.data.id, true);
}
},
[goToRow]
);
const handleRowClick = useCallback(
(invoice: IssuedInvoiceSummaryPageData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
); */
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>
);
}
// 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>*/}
</>
);
};

View File

@ -7,28 +7,29 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { DownloadIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
import { DownloadIcon, FileDownIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
import * as React from "react";
import QRCode from "react-qr-code";
import QrCode from "react-qr-code";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../schema";
import { useTranslation } from "../../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../../types";
type GridActionHandlers = {
onDownloadPdf?: (proforma: IssuedInvoiceSummaryData) => void;
onSendEmail?: (proforma: IssuedInvoiceSummaryData) => void;
onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void;
pdfDownloadingId?: string | null;
isPdfDownloading?: boolean;
};
export function useIssuedInvoicesGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<IssuedInvoiceSummaryData, unknown>[] {
const { t } = useTranslation();
const { onDownloadPdf, onSendEmail } = actionHandlers;
return React.useMemo<ColumnDef<IssuedInvoiceSummaryData>[]>(
() => [
@ -101,7 +102,7 @@ export function useIssuedInvoicesGridColumns(
</a>
</TooltipTrigger>
<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>
</Tooltip>
)}
@ -132,15 +133,15 @@ export function useIssuedInvoicesGridColumns(
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
const r = row.original.recipient;
return (
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate text-primary">{c.name}</span>
<span className="font-semibold truncate text-primary">{r.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
{r.tin && <span className="font-base truncate">{r.tin}</span>}
</div>
</div>
</div>
@ -321,29 +322,38 @@ export function useIssuedInvoicesGridColumns(
size: 64,
minSize: 64,
cell: ({ row }) => {
const proforma = row.original;
const issuedInvoice = row.original;
const isCompleted = issuedInvoice.verifactu.status === "Correcto";
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === issuedInvoice.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<ButtonGroup>
{/* Descargar en PDF */}
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary"
className="size-8"
disabled={isPDFLoading || !isCompleted}
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
stop(e);
isCompleted ? actionHandlers.onDownloadPdf?.(issuedInvoice) : null;
}}
size="icon-sm"
type="button"
size="icon"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
{isPDFLoading ? (
<Spinner className="size-4" />
) : (
<FileDownIcon className="size-4" />
)}
<span className="sr-only">Descargar PDF</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
<TooltipContent>Descargar PDF</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
@ -366,15 +376,12 @@ export function useIssuedInvoicesGridColumns(
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
onClick={() => actionHandlers.onDownloadPdf?.(issuedInvoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<DropdownMenuItem className="cursor-pointer" onClick={stop}>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
@ -389,6 +396,6 @@ export function useIssuedInvoicesGridColumns(
},
},
],
[t, onDownloadPdf, onSendEmail]
[t, actionHandlers]
);
}

View File

@ -0,0 +1 @@
export * from "./pages";

View File

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

View File

@ -0,0 +1,131 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, LockIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceListPageController } from "../../controllers";
import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
export const IssuedInvoiceListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
listCtrl,
handleDownloadPDF,
isPDFDownloading: isPdfDownloading,
pdfDownloadingId,
} = useIssuedInvoiceListPageController();
const columns = useIssuedInvoicesGridColumns({
onDownloadPdf: handleDownloadPDF,
pdfDownloadingId,
isPdfDownloading,
//onSendEmail: () => null,
});
// Hook con Sheet de shadcn
/*const preview = usePinnedPreviewSheet<IssuedInvoiceSummaryPageData>({
persistKey: "invoice-preview-pin",
widthClass: "w-[500px]",
});*/
if (listCtrl.isError || !listCtrl.data) {
return (
<AppContent>
<ErrorAlert
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.issued_invoices.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<Button
aria-label={t("pages.issued_invoices.create.title")}
onClick={() => navigate("/issued-invoices/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.issued_invoices.create.title")}
</Button>
}
title={t("pages.issued_invoices.list.title")}
/>
</AppHeader>
<AppContent>
<Alert className="bg-green-50 text-green-800">
<LockIcon />
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
<AlertDescription className="text-green-800">
Las facturas de esta pantalla son de solo lectura. Se generan automáticamente al emitir
una proforma aprobada y no se pueden modificar ni eliminar. Solo puedes descargar el PDF
de cada factura.
</AlertDescription>
</Alert>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
/>
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.issuedInvoices.status.all.label")}</SelectItem>
<SelectItem value="draft">
{t("catalog.issuedInvoices.status.draft.label")}
</SelectItem>
<SelectItem value="sent">{t("catalog.issuedInvoices.status.sent.label")}</SelectItem>
<SelectItem value="approved">
{t("catalog.issuedInvoices.status.approved.label")}
</SelectItem>
<SelectItem value="rejected">
{t("catalog.issuedInvoices.status.rejected.label")}
</SelectItem>
<SelectItem value="issued">
{t("catalog.issuedInvoices.status.issued.label")}
</SelectItem>
</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}
/>
</AppContent>
</>
);
};

View File

@ -1,2 +1,3 @@
export * from "./use-issued-invoices-grid-columns";
export * from "../../../list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns";
export * from "./use-issued-invoices-list";

View File

@ -1,61 +0,0 @@
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useIssuedInvoicesList } from "./hooks";
import { IssuedInvoicesGrid } from "./ui/issued-invoices-grid";
export const IssuedInvoiceListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const list = useIssuedInvoicesList();
if (list.isError || !list.data) {
return (
<AppContent>
<ErrorAlert
message={(list.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.issued_invoices.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<Button
aria-label={t("pages.issued_invoices.create.title")}
onClick={() => navigate("/issued-invoices/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.issued_invoices.create.title")}
</Button>
}
title={t("pages.issued_invoices.list.title")}
/>
</AppHeader>
<AppContent>
<IssuedInvoicesGrid
data={list.data}
loading={list.isLoading}
onPageChange={list.setPageIndex}
onPageSizeChange={list.setPageSize}
onSearchChange={list.setSearchValue}
pageIndex={list.pageIndex}
pageSize={list.pageSize}
searchValue={list.search}
/>
</AppContent>
</>
);
};

View File

@ -1,178 +0,0 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../hooks";
import { useTranslation } from "../../i18n";
import type { InvoiceSummaryFormData, InvoicesPageFormData } from "../../schemas";
export type InvoiceUpdateCompProps = {
invoicesPage: InvoicesPageFormData;
loading?: boolean;
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (
row: InvoiceSummaryFormData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const InvoicesListGrid = ({
invoicesPage,
loading,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
searchValue,
onSearchChange,
onRowClick,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = invoicesPage;
// Hook con Sheet de shadcn
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
persistKey: "invoice-preview-pin",
widthClass: "w-[500px]",
});
const [statusFilter, setStatusFilter] = useState("todas");
const columns = useProformasGridColumns({
onEdit: (invoice) => navigate(`/customer-invoices/${invoice.id}/edit`),
onDuplicate: (invoice) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
onDelete: (invoice) => null, //confirmDelete(inv.id),
});
// Navegación accesible (click o teclado)
const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);
const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
);
const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<any>) => {
if (!e.data) return;
const ev = e.event;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
ev.preventDefault();
goToRow(e.data.id);
}
if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
ev.preventDefault();
goToRow(e.data.id, true);
}
},
[goToRow]
);
const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
);
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>
);
}
// Render principal
return (
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
{/*<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" />
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="pagada">Pagadas</SelectItem>
<SelectItem value="pendiente">Pendientes</SelectItem>
<SelectItem value="vencida">Vencidas</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
<FileDownIcon className="mr-2 h-4 w-4" />
Exportar
</Button>*/}
</div>
<div className="relative flex">
<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>
{/*<preview.Preview>
{({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel
invoice={item}
isPinned={isPinned}
onClose={close}
onTogglePin={togglePin}
/>
)}
</preview.Preview>*/}
</div>
</div>
);
};

View File

@ -6,9 +6,9 @@ import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { invoiceResumeDtoToFormAdapter } from "../../adapters/invoice-resume-dto.adapter";
import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { issuedInvoiceResumeDtoToFormAdapter } from "../../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export const InvoiceListPage = () => {
const { t } = useTranslation();
@ -37,7 +37,7 @@ export const InvoiceListPage = () => {
if (!data) return undefined;
return {
...data,
items: invoiceResumeDtoToFormAdapter.fromDto(data.items),
items: issuedInvoiceResumeDtoToFormAdapter.fromDto(data.items),
};
}, [data]);

View File

@ -138,7 +138,7 @@ export function useProformasGridColumns(
return (
<div>
<a
className="text-primary hover:underline"
className="text-primary hover:underline font-semibold"
href={`/customers/${proforma.customer_id}`}
>
{proforma.recipient.name}

View File

@ -1,4 +1,4 @@
export * from "../adapters/invoice-resume-dto.adapter";
export * from "../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export * from "../proformas/adapters/proforma-dto.adapter";
export * from "./invoice.form.schema";